[
  {
    "path": ".agents/skills/daggr/SKILL.md",
    "content": "---\nname: daggr\ndescription: |\n  Build DAG-based AI pipelines connecting Gradio Spaces, HuggingFace models, and Python functions into visual workflows. Use when asked to create a workflow, build a pipeline, connect AI models, chain Gradio Spaces, create a daggr app, build multi-step AI applications, or orchestrate ML models. Triggers on: \"build a workflow\", \"create a pipeline\", \"connect models\", \"daggr\", \"chain Spaces\", \"AI pipeline\".\nlicense: MIT\nmetadata:\n  author: gradio-app\n  version: \"1.1\"\n---\n\n# daggr\n\nBuild visual DAG pipelines connecting Gradio Spaces, HF Inference Providers, and Python functions.\n\nFull docs: https://raw.githubusercontent.com/gradio-app/daggr/refs/heads/main/README.md\n\n## Quick Start\n\n```python\nfrom daggr import GradioNode, FnNode, InferenceNode, Graph, ItemList\nimport gradio as gr\n\ngraph = Graph(name=\"My Workflow\", nodes=[node1, node2, ...])\ngraph.launch()  # Starts web server with visual DAG UI\n```\n\n## Node Types\n\n### GradioNode - Gradio Spaces\n\n```python\nnode = GradioNode(\n    space_or_url=\"owner/space-name\",\n    api_name=\"/endpoint\",\n    inputs={\n        \"param\": gr.Textbox(label=\"Input\"),   # UI input\n        \"other\": other_node.output_port,       # Port connection\n        \"fixed\": \"constant_value\",             # Fixed value\n    },\n    postprocess=lambda *returns: returns[0],   # Transform response\n    outputs={\"result\": gr.Image(label=\"Output\")},\n)\n\n# Example: image generation\nimg = GradioNode(\"Tongyi-MAI/Z-Image-Turbo\", api_name=\"/generate\",\n    inputs={\"prompt\": gr.Textbox(), \"resolution\": \"1024x1024 ( 1:1 )\"},\n    postprocess=lambda imgs, *_: imgs[0][\"image\"],\n    outputs={\"image\": gr.Image()})\n```\n\nFind Spaces with semantic queries (describe what you need): `https://huggingface.co/api/spaces/semantic-search?q=generate+music+for+a+video&sdk=gradio&includeNonRunning=false`\nOr by category: `https://huggingface.co/api/spaces/semantic-search?category=image-generation&sdk=gradio&includeNonRunning=false`\n(categories: image-generation | video-generation | text-generation | speech-synthesis | music-generation | voice-cloning | image-editing | background-removal | image-upscaling | ocr | style-transfer | image-captioning)\n\n### FnNode - Python Functions\n\n```python\ndef process(input1: str, input2: int) -> str:\n    return f\"{input1}: {input2}\"\n\nnode = FnNode(\n    fn=process,\n    inputs={\"input1\": gr.Textbox(), \"input2\": other_node.port},\n    outputs={\"result\": gr.Textbox()},\n)\n```\n\n### InferenceNode - [HF Inference Providers](https://huggingface.co/docs/inference-providers)\n\nFind models: `https://huggingface.co/api/models?inference_provider=all&pipeline_tag=text-to-image`\n(swap pipeline_tag: text-to-image | image-to-image | image-to-text | image-to-video | text-to-video | text-to-speech | automatic-speech-recognition)\n\nVLM/LLM models: https://router.huggingface.co/v1/models\n\n```python\nnode = InferenceNode(\n    model=\"org/model:provider\",  # model:provider (fal-ai, replicate, together, etc.)\n    inputs={\"image\": other_node.image, \"prompt\": gr.Textbox()},\n    outputs={\"image\": gr.Image()},\n)\n```\n\n**Auth:** InferenceNode and ZeroGPU Spaces require a HF token. If not in env, ask user to create one:\n`https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained`\nOut of quota? Pro gives 8x ZeroGPU + 10x inference: `https://huggingface.co/subscribe/pro`\n\n## Port Connections\n\nPass ports via `inputs={...}`:\n```python\ninputs={\"param\": previous_node.output_port}       # Basic connection\ninputs={\"item\": items_node.items.field_name}      # Scattered (per-item)\ninputs={\"all\": scattered_node.output.all()}       # Gathered (collect list)\n```\n\n## ItemList - Dynamic Lists\n\n```python\ndef gen_items(n: int) -> list:\n    return [{\"text\": f\"Item {i}\"} for i in range(n)]\n\nitems = FnNode(fn=gen_items,\n    outputs={\"items\": ItemList(text=gr.Textbox())})\n\n# Runs once per item\nprocess = FnNode(fn=process_item,\n    inputs={\"text\": items.items.text},\n    outputs={\"result\": gr.Textbox()})\n\n# Collect all results\nfinal = FnNode(fn=combine,\n    inputs={\"all\": process.result.all()},\n    outputs={\"out\": gr.Textbox()})\n```\n\n## Checklist\n\n1. **Check API** before using a Space:\n   ```bash\n   curl -s \"https://<space-subdomain>.hf.space/gradio_api/openapi.json\"\n   ```\n   Replace `<space-subdomain>` with the Space's subdomain (e.g., `Tongyi-MAI/Z-Image-Turbo` → `tongyi-mai-z-image-turbo`).\n   (Spaces also have \"Use via API\" link in footer with endpoints and code snippets)\n\n2. **Handle files** (Gradio returns dicts):\n   ```python\n   path = file.get(\"path\") if isinstance(file, dict) else file\n   ```\n\n3. **Use postprocess** for multi-return APIs:\n   ```python\n   postprocess=lambda imgs, seed, num: imgs[0][\"image\"]\n   ```\n\n4. **Debug with `.test()`** to validate a node in isolation:\n   ```python\n   node.test(param=\"value\")\n   ```\n\n## Common Patterns\n\n```python\n# Image Generation\nGradioNode(\"Tongyi-MAI/Z-Image-Turbo\", api_name=\"/generate\",\n    inputs={\"prompt\": gr.Textbox(), \"resolution\": \"1024x1024 ( 1:1 )\"},\n    postprocess=lambda imgs, *_: imgs[0][\"image\"],\n    outputs={\"image\": gr.Image()})\n\n# Text-to-Speech\nGradioNode(\"Qwen/Qwen3-TTS\", api_name=\"/generate_voice_design\",\n    inputs={\"text\": gr.Textbox(), \"language\": \"English\", \"voice_description\": \"...\"},\n    postprocess=lambda audio, status: audio,\n    outputs={\"audio\": gr.Audio()})\n\n# Image-to-Video\nGradioNode(\"alexnasa/ltx-2-TURBO\", api_name=\"/generate_video\",\n    inputs={\"input_image\": img.image, \"prompt\": gr.Textbox(), \"duration\": 5},\n    postprocess=lambda video, seed: video,\n    outputs={\"video\": gr.Video()})\n\n# ffmpeg composition (import tempfile, subprocess)\ndef combine(video: str|dict, audio: str|dict) -> str:\n    v = video.get(\"path\") if isinstance(video, dict) else video\n    a = audio.get(\"path\") if isinstance(audio, dict) else audio\n    out = tempfile.mktemp(suffix=\".mp4\")\n    subprocess.run([\"ffmpeg\",\"-y\",\"-i\",v,\"-i\",a,\"-shortest\",out])\n    return out\n```\n\n## Run\n\n```bash\nuvx --python 3.12 daggr workflow.py &  # Launch in background, hot reloads on file changes\n```\n\n## Authentication\n\n**Local development:** Use `hf auth login` or set `HF_TOKEN` env var. This enables ZeroGPU quota tracking, private Spaces access, and gated models.\n\n**Deployed Spaces:** Users can click \"Login\" in the UI and paste their HF token. This enables persistence (sheets) so they can save outputs and resume work later. The token is stored in browser localStorage.\n\n**When deploying:** Pass secrets via `--secret HF_TOKEN=xxx` if your workflow needs server-side auth (e.g., for gated models in FnNode). Warning: this uses the deployer's token for all users.\n\n## Deploy to Hugging Face Spaces\n\nOnly deploy if the user has explicitly asked to publish/deploy their workflow.\n\n```bash\ndaggr deploy workflow.py\n```\n\nThis extracts the Graph, creates a Space named after it, and uploads everything.\n\n**Options:**\n```bash\ndaggr deploy workflow.py --name my-space      # Custom Space name\ndaggr deploy workflow.py --org huggingface    # Deploy to an organization\ndaggr deploy workflow.py --private            # Private Space\ndaggr deploy workflow.py --hardware t4-small  # GPU (t4-small, t4-medium, a10g-small, etc.)\ndaggr deploy workflow.py --secret KEY=value   # Add secrets (repeatable)\ndaggr deploy workflow.py --dry-run            # Preview without deploying\n```\n"
  },
  {
    "path": ".changeset/README.md",
    "content": "# Changesets\n\nHello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works\nwith multi-package repos, or single-package repos to help you version and publish your code. You can\nfind the full documentation for it [in our repository](https://github.com/changesets/changesets)\n\nWe have a quick list of common questions to get you started engaging with this project in\n[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)\n"
  },
  {
    "path": ".changeset/changeset.cjs",
    "content": "const { getPackagesSync } = require(\"@manypkg/get-packages\");\nconst dependents_graph = require(\"@changesets/get-dependents-graph\");\n\nconst gh = require(\"@changesets/get-github-info\");\nconst { existsSync, readFileSync, writeFileSync } = require(\"fs\");\nconst { join } = require(\"path\");\n\nconst { getInfo, getInfoFromPullRequest } = gh;\nconst pkg_data = getPackagesSync(process.cwd());\nconst { packages, rootDir } = pkg_data;\nconst dependents = dependents_graph.getDependentsGraph({\n\tpackages,\n\troot: pkg_data.rootPackage\n});\n\n/**\n * @typedef {{packageJson: {name: string, python?: boolean}, dir: string}} Package\n */\n\n/**\n * @typedef {{summary: string, id: string, commit: string, releases: {name: string}}} Changeset\n */\n\n/**\n *\n * @param {string} package_name The name of the package to find the directories for\n * @returns {string[]} The directories for the package\n */\nfunction find_packages_dirs(package_name) {\n\t/** @type {string[]} */\n\tlet package_dirs = [];\n\n\t/** @type {Package | undefined} */\n\tconst _package = packages.find((p) => p.packageJson.name === package_name);\n\tif (!_package) throw new Error(`Package ${package_name} not found`);\n\n\tpackage_dirs.push(_package.dir);\n\tif (_package.packageJson.python) {\n\t\tpackage_dirs.push(join(_package.dir, \"..\"));\n\t}\n\treturn package_dirs;\n}\n\nlet lines = {\n\t_handled: []\n};\n\nconst changelogFunctions = {\n\t/**\n\t *\n\t * @param {Changeset[]} changesets The changesets that have been created\n\t * @param {any} dependenciesUpdated The dependencies that have been updated\n\t * @param {any} options The options passed to the changelog generator\n\t * @returns {Promise<string>} The release line for the dependencies\n\t */\n\tgetDependencyReleaseLine: async (\n\t\tchangesets,\n\t\tdependenciesUpdated,\n\t\toptions\n\t) => {\n\t\tif (!options.repo) {\n\t\t\tthrow new Error(\n\t\t\t\t'Please provide a repo to this changelog generator like this:\\n\"changelog\": [\"@changesets/changelog-github\", { \"repo\": \"org/repo\" }]'\n\t\t\t);\n\t\t}\n\t\tif (dependenciesUpdated.length === 0) return \"\";\n\n\t\tconst changesetLink = `- Updated dependencies [${(\n\t\t\tawait Promise.all(\n\t\t\t\tchangesets.map(async (cs) => {\n\t\t\t\t\tif (cs.commit) {\n\t\t\t\t\t\tlet { links } = await getInfo({\n\t\t\t\t\t\t\trepo: options.repo,\n\t\t\t\t\t\t\tcommit: cs.commit\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn links.commit;\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t)\n\t\t)\n\t\t\t.filter((_) => _)\n\t\t\t.join(\", \")}]:`;\n\n\t\tconst updatedDepenenciesList = dependenciesUpdated.map(\n\t\t\t/**\n\t\t\t *\n\t\t\t * @param {any} dependency The dependency that has been updated\n\t\t\t * @returns {string} The formatted dependency\n\t\t\t */\n\t\t\t(dependency) => {\n\t\t\t\tconst updates = dependents.get(dependency.name);\n\n\t\t\t\tif (updates && updates.length > 0) {\n\t\t\t\t\tupdates.forEach((update) => {\n\t\t\t\t\t\tif (!lines[update]) {\n\t\t\t\t\t\t\tlines[update] = {\n\t\t\t\t\t\t\t\tdirs: find_packages_dirs(update),\n\t\t\t\t\t\t\t\tcurrent_changelog: \"\",\n\t\t\t\t\t\t\t\tfeat: [],\n\t\t\t\t\t\t\t\tfix: [],\n\t\t\t\t\t\t\t\thighlight: [],\n\t\t\t\t\t\t\t\tprevious_version: packages.find(\n\t\t\t\t\t\t\t\t\t(p) => p.packageJson.name === update\n\t\t\t\t\t\t\t\t).packageJson.version,\n\t\t\t\t\t\t\t\tdependencies: []\n\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\tconst changelog_path = join(\n\t\t\t\t\t\t\t\t//@ts-ignore\n\t\t\t\t\t\t\t\tlines[update].dirs[1] || lines[update].dirs[0],\n\t\t\t\t\t\t\t\t\"CHANGELOG.md\"\n\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\tif (existsSync(changelog_path)) {\n\t\t\t\t\t\t\t\t//@ts-ignore\n\t\t\t\t\t\t\t\tlines[update].current_changelog = readFileSync(\n\t\t\t\t\t\t\t\t\tchangelog_path,\n\t\t\t\t\t\t\t\t\t\"utf-8\"\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t.replace(`# ${update}`, \"\")\n\t\t\t\t\t\t\t\t\t.trim();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tlines[update].dependencies.push(\n\t\t\t\t\t\t\t`  - ${dependency.name}@${dependency.newVersion}`\n\t\t\t\t\t\t);\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\treturn `  - ${dependency.name}@${dependency.newVersion}`;\n\t\t\t}\n\t\t);\n\n\t\twriteFileSync(\n\t\t\tjoin(rootDir, \".changeset\", \"_changelog.json\"),\n\t\t\tJSON.stringify(lines, null, 2)\n\t\t);\n\n\t\treturn [changesetLink, ...updatedDepenenciesList].join(\"\\n\");\n\t},\n\t/**\n\t *\n\t * @param {{summary: string, id: string, commit: string, releases: {name: string}[]}} changeset The changeset that has been created\n\t * @param {any} type The type of changeset\n\t * @param {any} options The options passed to the changelog generator\n\t * @returns {Promise<string>} The release line for the changeset\n\t */\n\tgetReleaseLine: async (changeset, type, options) => {\n\t\tif (!options || !options.repo) {\n\t\t\tthrow new Error(\n\t\t\t\t'Please provide a repo to this changelog generator like this:\\n\"changelog\": [\"@changesets/changelog-github\", { \"repo\": \"org/repo\" }]'\n\t\t\t);\n\t\t}\n\n\t\tlet prFromSummary;\n\t\tlet commitFromSummary;\n\t\t/**\n\t\t * @type {string[]}\n\t\t */\n\t\tlet usersFromSummary = [];\n\n\t\tconst replacedChangelog = changeset.summary\n\t\t\t.replace(/^\\s*(?:pr|pull|pull\\s+request):\\s*#?(\\d+)/im, (_, pr) => {\n\t\t\t\tlet num = Number(pr);\n\t\t\t\tif (!isNaN(num)) prFromSummary = num;\n\t\t\t\treturn \"\";\n\t\t\t})\n\t\t\t.replace(/^\\s*commit:\\s*([^\\s]+)/im, (_, commit) => {\n\t\t\t\tcommitFromSummary = commit;\n\t\t\t\treturn \"\";\n\t\t\t})\n\t\t\t.replace(/^\\s*(?:author|user):\\s*@?([^\\s]+)/gim, (_, user) => {\n\t\t\t\tusersFromSummary.push(user);\n\t\t\t\treturn \"\";\n\t\t\t})\n\t\t\t.trim();\n\n\t\tconst [firstLine, ...futureLines] = replacedChangelog\n\t\t\t.split(\"\\n\")\n\t\t\t.map((l) => l.trimRight());\n\n\t\tconst links = await (async () => {\n\t\t\tif (prFromSummary !== undefined) {\n\t\t\t\tlet { links } = await getInfoFromPullRequest({\n\t\t\t\t\trepo: options.repo,\n\t\t\t\t\tpull: prFromSummary\n\t\t\t\t});\n\t\t\t\tif (commitFromSummary) {\n\t\t\t\t\tlinks = {\n\t\t\t\t\t\t...links,\n\t\t\t\t\t\tcommit: `[\\`${commitFromSummary}\\`](https://github.com/${options.repo}/commit/${commitFromSummary})`\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\treturn links;\n\t\t\t}\n\t\t\tconst commitToFetchFrom = commitFromSummary || changeset.commit;\n\t\t\tif (commitToFetchFrom) {\n\t\t\t\tlet { links } = await getInfo({\n\t\t\t\t\trepo: options.repo,\n\t\t\t\t\tcommit: commitToFetchFrom\n\t\t\t\t});\n\t\t\t\treturn links;\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tcommit: null,\n\t\t\t\tpull: null,\n\t\t\t\tuser: null\n\t\t\t};\n\t\t})();\n\n\t\tconst user_link = /\\[(@[^]+)\\]/.exec(links.user);\n\t\tconst users =\n\t\t\tusersFromSummary && usersFromSummary.length\n\t\t\t\t? usersFromSummary\n\t\t\t\t\t\t.map((userFromSummary) => `@${userFromSummary}`)\n\t\t\t\t\t\t.join(\", \")\n\t\t\t\t: user_link\n\t\t\t\t\t? user_link[1]\n\t\t\t\t\t: links.user;\n\n\t\tconst prefix = [\n\t\t\tlinks.pull === null ? \"\" : `${links.pull}`,\n\t\t\tlinks.commit === null ? \"\" : `${links.commit}`\n\t\t]\n\t\t\t.join(\" \")\n\t\t\t.trim();\n\n\t\tconst suffix = users === null ? \"\" : ` Thanks ${users}!`;\n\n\t\t/**\n\t\t * @typedef {{[key: string]: string[] | {dirs: string[], current_changelog: string, feat: {summary: string}[], fix: {summary: string}[], highlight: {summary: string}[]}}} ChangesetMeta\n\t\t */\n\n\t\t/**\n\t\t * @type { ChangesetMeta & { _handled: string[] } }}\n\t\t */\n\n\t\tif (lines._handled.includes(changeset.id)) {\n\t\t\treturn \"done\";\n\t\t}\n\t\tlines._handled.push(changeset.id);\n\n\t\tchangeset.releases.forEach((release) => {\n\t\t\tif (!lines[release.name]) {\n\t\t\t\tlines[release.name] = {\n\t\t\t\t\tdirs: find_packages_dirs(release.name),\n\t\t\t\t\tcurrent_changelog: \"\",\n\t\t\t\t\tfeat: [],\n\t\t\t\t\tfix: [],\n\t\t\t\t\thighlight: [],\n\t\t\t\t\tprevious_version: packages.find(\n\t\t\t\t\t\t(p) => p.packageJson.name === release.name\n\t\t\t\t\t).packageJson.version,\n\t\t\t\t\tdependencies: []\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst changelog_path = join(\n\t\t\t\t//@ts-ignore\n\t\t\t\tlines[release.name].dirs[1] || lines[release.name].dirs[0],\n\t\t\t\t\"CHANGELOG.md\"\n\t\t\t);\n\n\t\t\tif (existsSync(changelog_path)) {\n\t\t\t\t//@ts-ignore\n\t\t\t\tlines[release.name].current_changelog = readFileSync(\n\t\t\t\t\tchangelog_path,\n\t\t\t\t\t\"utf-8\"\n\t\t\t\t)\n\t\t\t\t\t.replace(`# ${release.name}`, \"\")\n\t\t\t\t\t.trim();\n\t\t\t}\n\n\t\t\tconst [, _type, summary] = changeset.summary\n\t\t\t\t.trim()\n\t\t\t\t.match(/^(feat|fix|highlight)\\s*:\\s*([^]*)/im) || [\n\t\t\t\t,\n\t\t\t\t\"feat\",\n\t\t\t\tchangeset.summary\n\t\t\t];\n\n\t\t\tlet formatted_summary = \"\";\n\n\t\t\tif (_type === \"highlight\") {\n\t\t\t\tconst [heading, ...rest] = summary.trim().split(\"\\n\");\n\t\t\t\tconst _heading = `${heading} ${prefix ? `(${prefix})` : \"\"}`;\n\t\t\t\tconst _rest = rest.concat([\"\", suffix]);\n\n\t\t\t\tformatted_summary = `${_heading}\\n${_rest.join(\"\\n\")}`;\n\t\t\t} else {\n\t\t\t\tformatted_summary = handle_line(summary, prefix, suffix);\n\t\t\t}\n\n\t\t\t//@ts-ignore\n\t\t\tlines[release.name][_type].push({\n\t\t\t\tsummary: formatted_summary\n\t\t\t});\n\t\t});\n\n\t\twriteFileSync(\n\t\t\tjoin(rootDir, \".changeset\", \"_changelog.json\"),\n\t\t\tJSON.stringify(lines, null, 2)\n\t\t);\n\n\t\treturn `\\n\\n-${prefix ? `${prefix} -` : \"\"} ${firstLine}\\n${futureLines\n\t\t\t.map((l) => `  ${l}`)\n\t\t\t.join(\"\\n\")}`;\n\t}\n};\n\n/**\n * @param {string} str The changelog entry\n * @param {string} prefix The prefix to add to the first line\n * @param {string} suffix The suffix to add to the last line\n * @returns {string} The formatted changelog entry\n */\nfunction handle_line(str, prefix, suffix) {\n\tconst [_s, ...lines] = str.split(\"\\n\").filter(Boolean);\n\n\tconst desc = `${prefix ? `${prefix} -` : \"\"} ${_s.replace(\n\t\t/[\\s\\.]$/,\n\t\t\"\"\n\t)}. ${suffix}`;\n\n\tif (_s.length === 1) {\n\t\treturn desc;\n\t}\n\n\treturn [desc, ...lines.map((l) => `  ${l}`)].join(\"/n\");\n}\n\nmodule.exports = changelogFunctions;\n"
  },
  {
    "path": ".changeset/config.json",
    "content": "{\n\t\"$schema\": \"https://unpkg.com/@changesets/config@2.3.0/schema.json\",\n\t\"changelog\": [\"./changeset.cjs\", { \"repo\": \"abidlabs/daggr\" }],\n\t\"commit\": false,\n\t\"fixed\": [],\n\t\"linked\": [],\n\t\"access\": \"public\",\n\t\"baseBranch\": \"main\",\n\t\"updateInternalDependencies\": \"patch\"\n}\n"
  },
  {
    "path": ".changeset/fix_changelogs.cjs",
    "content": "const { join } = require(\"path\");\nconst { readFileSync, existsSync, writeFileSync, unlinkSync } = require(\"fs\");\nconst { getPackagesSync } = require(\"@manypkg/get-packages\");\n\nconst RE_PKG_NAME = /^[\\w-]+\\b/;\nconst pkg_meta = getPackagesSync(process.cwd());\n\n/**\n * @typedef {{dirs: string[], highlight: {summary: string}[], feat: {summary: string}[], fix: {summary: string}[], current_changelog: string}} ChangesetMeta\n */\n\n/**\n * @typedef {{[key: string]: ChangesetMeta}} ChangesetMetaCollection\n */\n\nfunction run() {\n\tif (!existsSync(join(pkg_meta.rootDir, \".changeset\", \"_changelog.json\"))) {\n\t\tconsole.warn(\"No changesets to process\");\n\t\treturn;\n\t}\n\n\t/**\n\t * @type { ChangesetMetaCollection & { _handled: string[] } }}\n\t */\n\tconst { _handled, ...packages } = JSON.parse(\n\t\treadFileSync(\n\t\t\tjoin(pkg_meta.rootDir, \".changeset\", \"_changelog.json\"),\n\t\t\t\"utf-8\"\n\t\t)\n\t);\n\n\t/**\n\t * @typedef { {packageJson: {name: string, version: string, python: boolean}, dir: string} } PackageMeta\n\t */\n\n\t/**\n\t * @type { {[key:string]: PackageMeta} }\n\t */\n\tconst all_packages = pkg_meta.packages.reduce((acc, pkg) => {\n\t\tacc[pkg.packageJson.name] = /**@type {PackageMeta} */ (\n\t\t\t/** @type {unknown} */ (pkg)\n\t\t);\n\t\treturn acc;\n\t}, /** @type {{[key:string] : PackageMeta}} */ ({}));\n\n\tfor (const pkg_name in packages) {\n\t\tconst { dirs, highlight, feat, fix, current_changelog, dependencies } =\n\t\t\t/**@type {ChangesetMeta} */ (packages[pkg_name]);\n\n\t\tconst { version, python } = all_packages[pkg_name].packageJson;\n\n\t\tconst highlights = highlight?.map((h) => `${h.summary}`) || [];\n\t\tconst features = feat?.map((f) => `- ${f.summary}`) || [];\n\t\tconst fixes = fix?.map((f) => `- ${f.summary}`) || [];\n\t\tconst deps = Array.from(new Set(dependencies?.map((d) => d.trim()))) || [];\n\n\t\tconst release_notes = /** @type {[string[], string][]} */ ([\n\t\t\t[highlights, \"### Highlights\"],\n\t\t\t[features, \"### Features\"],\n\t\t\t[fixes, \"### Fixes\"],\n\t\t\t[deps, \"### Dependency updates\"],\n\t\t])\n\t\t\t.filter(([s], i) => s.length > 0)\n\t\t\t.map(([lines, title]) => {\n\t\t\t\tif (title === \"### Highlights\") {\n\t\t\t\t\treturn `${title}\\n\\n${lines.join(\"\\n\\n\")}`;\n\t\t\t\t}\n\n\t\t\t\treturn `${title}\\n\\n${lines.join(\"\\n\")}`;\n\t\t\t})\n\t\t\t.join(\"\\n\\n\");\n\n\t\tconst new_changelog = `# ${pkg_name}\n\n## ${version}\n\n${release_notes}\n\n${current_changelog.replace(`# ${pkg_name}`, \"\").trim()}\n`.trim();\n\n\t\tdirs.forEach((dir) => {\n\t\t\twriteFileSync(join(dir, \"CHANGELOG.md\"), new_changelog);\n\t\t});\n\n\t\tif (python) {\n\t\t\tbump_local_dependents(pkg_name, version);\n\t\t}\n\t}\n\n\tunlinkSync(join(pkg_meta.rootDir, \".changeset\", \"_changelog.json\"));\n\n\t/**\n\t * @param {string} pkg_to_bump The name of the package to bump\n\t * @param {string} version The version to bump to\n\t * @returns {void}\n\t * */\n\tfunction bump_local_dependents(pkg_to_bump, version) {\n\t\tfor (const pkg_name in all_packages) {\n\t\t\tconst {\n\t\t\t\tdir,\n\t\t\t\tpackageJson: { python },\n\t\t\t} = all_packages[pkg_name];\n\n\t\t\tif (!python) continue;\n\n\t\t\tconst requirements_path = join(dir, \"..\", \"requirements.txt\");\n\n\t\t\tif (!existsSync(requirements_path)) continue;\n\t\t\tconst requirements = readFileSync(requirements_path, \"utf-8\").split(\"\\n\");\n\n\t\t\tconst pkg_index = requirements.findIndex((line) => {\n\t\t\t\tconst m = line.trim().match(RE_PKG_NAME);\n\t\t\t\tif (!m) return false;\n\t\t\t\treturn m[0] === pkg_to_bump;\n\t\t\t});\n\n\t\t\tif (pkg_index !== -1) {\n\t\t\t\trequirements[pkg_index] = `${pkg_to_bump}==${version}`;\n\t\t\t\twriteFileSync(requirements_path, requirements.join(\"\\n\"));\n\t\t\t}\n\t\t}\n\t}\n}\n\nrun();\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "Thank you for your contribution! All PRs should include the following sections. PRs missing these sections may be closed immediately.\n\n## Short description\n\nThis PR... *[fill here]*\n\n## AI Disclosure\n\nWe encourage the use of AI tooling in creating PRs, but the any non-trivial use of AI needs be disclosed. E.g. if you used Claude to write a first draft, you should mention that. Trivial tab-completion doesn't need to be disclosed. **You should self-review all PRs, especially if they were generated with AI**. \n\n-----\n\n- [ ] I used AI to... *[fill here]*\n- [ ] I did not use AI\n\n----\n\n## Type of Change\n\n- [ ] Bug fix\n- [ ] New feature (non-breaking)\n- [ ] New feature (breaking change)\n- [ ] Documentation update\n- [ ] Test improvements\n\n## Related Issues\n\nIf this PR closes an issue, please link it below: \n\nCloses: \n\n## Testing and linting\n\nPlease run tests before submitting changes:\n   ```bash\n   python -m pytest\n   ```\n\nand format your code using Ruff:\n\n   ```bash\n   ruff check --fix --select I && ruff format\n   ```\n"
  },
  {
    "path": ".github/workflows/comment-queue.yml",
    "content": "name: Comment on pull request without race conditions\n\non:\n  workflow_call:\n    inputs:\n      pr_number:\n        type: string\n      message:\n        required: true\n        type: string\n      tag:\n        required: false\n        type: string\n        default: \"previews\"\n      additional_text:\n        required: false\n        type: string\n        default: \"\"\n    secrets:\n      gh_token:\n        required: true\n\njobs:\n  comment:\n    environment: comment_pr\n    concurrency:\n      group: ${{inputs.pr_number || inputs.tag}}\n    runs-on: ubuntu-latest\n    steps:\n      - name: comment on pr\n        uses: \"gradio-app/github/actions/comment-pr@main\"\n        with:\n          gh_token: ${{ secrets.gh_token }}\n          tag: ${{ inputs.tag }}\n          pr_number: ${{ inputs.pr_number}}\n          message: ${{ inputs.message }}\n          additional_text: ${{ inputs.additional_text }}\n"
  },
  {
    "path": ".github/workflows/format.yml",
    "content": "name: Format\n\non:\n  pull_request:\n    branches: [ main ]\n  push:\n    branches: [ main ]\n\njobs:\n  format:\n    runs-on: ubuntu-latest\n    \n    steps:\n    - uses: actions/checkout@v3\n    \n    - name: Set up Python\n      uses: actions/setup-python@v4\n      with:\n        python-version: '3.10'\n        \n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install ruff\n        \n    - name: Check formatting with ruff\n      run: |\n        ruff format --check .\n        \n    - name: Check code with ruff\n      run: |\n        ruff check . "
  },
  {
    "path": ".github/workflows/generate-changeset.yml",
    "content": "name: Generate changeset\non:\n  workflow_run:\n    workflows: [\"trigger-changeset\"]\n    types:\n      - completed\n\nenv:\n  CI: true\n\nconcurrency:\n  group: \"${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}\"\n  cancel-in-progress: false\n\npermissions: {}\n\njobs:\n  get-pr:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n    if: github.event.workflow_run.conclusion == 'success'\n    outputs:\n      found_pr: ${{ steps.pr_details.outputs.found_pr }}\n      pr_number: ${{ steps.pr_details.outputs.pr_number }}\n      source_repo: ${{ steps.pr_details.outputs.source_repo }}\n      source_branch: ${{ steps.pr_details.outputs.source_branch }}\n      actor: ${{ steps.pr_details.outputs.actor }}\n      sha: ${{ steps.pr_details.outputs.sha }}\n    steps:\n      - name: get pr details\n        id: pr_details\n        uses: gradio-app/github/actions/find-pr@main\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n  comment-changes-start:\n    uses: \"./.github/workflows/comment-queue.yml\"\n    needs: get-pr\n    secrets:\n      gh_token: ${{ secrets.GRADIO_PAT }}\n    with:\n      pr_number: ${{ needs.get-pr.outputs.pr_number }}\n      message: changes~pending~null\n  version:\n    permissions:\n      contents: read\n    name: version\n    needs: get-pr\n    runs-on: ubuntu-latest\n    if: needs.get-pr.outputs.found_pr == 'true'\n    outputs:\n      skipped: ${{ steps.version.outputs.skipped }}\n      comment_url: ${{ steps.version.outputs.comment_url }}\n      approved: ${{ steps.version.outputs.approved }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          repository: ${{ needs.get-pr.outputs.source_repo }}\n          ref: ${{ needs.get-pr.outputs.source_branch }}\n          fetch-depth: 0\n          token: ${{ secrets.GRADIO_PAT }}\n      - name: generate changeset\n        id: version\n        uses: \"gradio-app/github/actions/generate-changeset@main\"\n        with:\n          github_token: ${{ secrets.GRADIO_PAT }}\n          main_pkg: daggr\n          pr_number: ${{ needs.get-pr.outputs.pr_number }}\n          branch_name: ${{ needs.get-pr.outputs.source_branch }}\n          actor: ${{ needs.get-pr.outputs.actor }}\n  update-status:\n    permissions:\n      actions: read\n      statuses: write\n    runs-on: ubuntu-latest\n    needs: [version, get-pr]\n    steps:\n      - name: update status\n        uses: gradio-app/github/actions/commit-status@main\n        with:\n          sha: ${{ needs.get-pr.outputs.sha }}\n          token: ${{ secrets.COMMIT_STATUS }}\n          name: \"Changeset Results\"\n          pr: ${{ needs.get-pr.outputs.pr_number }}\n          result: 'success'\n          type: all\n  comment-changes-skipped:\n    uses: \"./.github/workflows/comment-queue.yml\"\n    needs: [get-pr, version]\n    if: needs.version.result == 'success' && needs.version.outputs.skipped == 'true'\n    secrets:\n      gh_token: ${{ secrets.GRADIO_PAT }}\n    with:\n      pr_number: ${{ needs.get-pr.outputs.pr_number }}\n      message: changes~warning~https://github.com/abidlabs/daggr/actions/runs/${{github.run_id}}/\n  comment-changes-success:\n    uses: \"./.github/workflows/comment-queue.yml\"\n    needs: [get-pr, version]\n    if: needs.version.result == 'success' && needs.version.outputs.skipped == 'false'\n    secrets:\n      gh_token: ${{ secrets.GRADIO_PAT }}\n    with:\n      pr_number: ${{ needs.get-pr.outputs.pr_number }}\n      message: changes~success~${{ needs.version.outputs.comment_url }}\n  comment-changes-failure:\n    uses: \"./.github/workflows/comment-queue.yml\"\n    needs: [get-pr, version]\n    if: always() && needs.version.result == 'failure'\n    secrets:\n      gh_token: ${{ secrets.GRADIO_PAT }}\n    with:\n      pr_number: ${{ needs.get-pr.outputs.pr_number }}\n      message: changes~failure~https://github.com/abidlabs/daggr/actions/runs/${{github.run_id}}/\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "# safe runs from main\n\nname: publish\non:\n  push:\n    branches:\n      - main\n\njobs:\n  version_or_publish:\n    runs-on: ubuntu-22.04\n    environment: publish\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - name: checkout repo\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n      - name: Install pnpm\n        uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # @v4\n        with:\n          version: 9.1.x\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: pnpm\n          cache-dependency-path: pnpm-lock.yaml\n      - name: Install changesets\n        run: pnpm i --frozen-lockfile\n      - name: create and publish versions\n        id: changesets\n        uses: changesets/action@aba318e9165b45b7948c60273e0b72fce0a64eb9 # @v1\n        with:\n          version: pnpm ci:version\n          commit: \"chore: update versions\"\n          title: \"chore: update versions\"\n          publish: pnpm ci:tag\n        env:\n          GITHUB_TOKEN: ${{ secrets.GRADIO_PAT }}\n      - name: Build frontend\n        if: steps.changesets.outputs.hasChangesets != 'true'\n        run: |\n          cd daggr/frontend\n          npm ci\n          npm run build\n      - name: publish to pypi\n        if: steps.changesets.outputs.hasChangesets != 'true'\n        uses: \"gradio-app/github/actions/publish-pypi@main\"\n        with:\n          use-oidc: true\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Unit Tests\n\non:\n  pull_request:\n    branches: [ main ]\n  push:\n    branches: [ main ]\n\njobs:\n  test:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n    \n    steps:\n    - uses: actions/checkout@v3\n    \n    - name: Set up Python\n      uses: actions/setup-python@v4\n      with:\n        python-version: '3.10'\n\n    - name: Set up Node.js\n      uses: actions/setup-node@v4\n      with:\n        node-version: '20'\n        \n    - name: Install uv\n      uses: astral-sh/setup-uv@v3\n      with:\n        version: \"latest\"\n        \n    - name: Cache uv packages\n      uses: actions/cache@v3\n      with:\n        path: ~/.cache/uv\n        key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }}\n        restore-keys: |\n          ${{ runner.os }}-uv-\n\n    - name: Build frontend\n      run: |\n        cd daggr/frontend\n        npm ci\n        npm run build\n        \n    - name: Install Python dependencies\n      run: |\n        uv pip install --system -e .[dev] --prerelease=allow\n        uv pip install --system pytest --prerelease=allow\n\n    - name: Install Playwright browsers (Ubuntu only)\n      if: runner.os == 'Linux'\n      run: |\n        playwright install chromium --with-deps\n        \n    - name: Run all tests (Ubuntu)\n      if: runner.os == 'Linux'\n      run: |\n        pytest\n\n    - name: Run tests without UI (Windows)\n      if: runner.os == 'Windows'\n      run: |\n        pytest --ignore=tests/ui/\n"
  },
  {
    "path": ".github/workflows/trigger-changeset.yml",
    "content": "name: trigger-changeset\non:\n  pull_request:\n    types: [opened, synchronize, reopened, edited, labeled, unlabeled]\n    branches:\n      - main\n  issue_comment:\n    types: [edited]\n\npermissions: {}\n\njobs:\n  changeset:\n    runs-on: ubuntu-22.04\n    if: github.event.sender.login != 'gradio-pr-bot'\n    steps:\n      - run: echo \"Requesting changeset\"\n"
  },
  {
    "path": ".gitignore",
    "content": "trackio/__pycache__/\ntests/__pycache__/\ntest-results/\n.trackio/\n.gradio/\ntrackio.db\n*.pyc\n*.pyi\n*.claude/\n.venv/\n**/.DS_Store\n*.daggr_sessions.db\nnode_modules/\n/dist/\nexamples/test.py\ndaggr/frontend/dist/\nbuild/\ntf_test_run/\nvenv\n.vscode/\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# daggr\n\n## 0.8.0\n\n### Features\n\n- [#79](https://github.com/gradio-app/daggr/pull/79) [`d32cec2`](https://github.com/gradio-app/daggr/commit/d32cec21e93532ce7a7e66815922985c0bd5cb75) - feat: Introduce `InputNode` for grouping UI controls.  Thanks @elismasilva!\n- [#81](https://github.com/gradio-app/daggr/pull/81) [`34267c9`](https://github.com/gradio-app/daggr/commit/34267c9f93729cc4fa4839b15feb44a239897ec9) - stylized thin scrollbars.  Thanks @elismasilva!\n- [#77](https://github.com/gradio-app/daggr/pull/77) [`9002cca`](https://github.com/gradio-app/daggr/commit/9002cca2b312731572d4d80d7efe3cd9483e19b3) - Feature: Toggle Dark/Light Mode (based on @elismasilva's changes).  Thanks @abidlabs!\n\n## 0.7.0\n\n### Features\n\n- [#69](https://github.com/gradio-app/daggr/pull/69) [`297d104`](https://github.com/gradio-app/daggr/commit/297d104d0e3fbd6b59dfcd1f69c9a478de81bc3d) - Add stop button to cancel running nodes.  Thanks @abidlabs!\n- [#64](https://github.com/gradio-app/daggr/pull/64) [`a9e53c6`](https://github.com/gradio-app/daggr/commit/a9e53c64db6b3beede0b19b3876a3e50ab572233) - Fix ChoiceNode: public .name property and respect explicit names.  Thanks @abidlabs!\n- [#62](https://github.com/gradio-app/daggr/pull/62) [`695411c`](https://github.com/gradio-app/daggr/commit/695411ce94bc8fedd3320ac941ab41233bf8f887) - Standardize file handling: all files are path strings.  Thanks @abidlabs!\n- [#66](https://github.com/gradio-app/daggr/pull/66) [`1ed16f8`](https://github.com/gradio-app/daggr/commit/1ed16f806d535413c3718fc47d81d79d93d73ee0) - Fix gr.JSON rendering (use @render snippet syntax).  Thanks @abidlabs!\n- [#63](https://github.com/gradio-app/daggr/pull/63) [`00b05ac`](https://github.com/gradio-app/daggr/commit/00b05ac526642c91560cbebb258626a4511082c2) - Fix file downloads from private HF Spaces.  Thanks @abidlabs!\n- [#70](https://github.com/gradio-app/daggr/pull/70) [`33ccb74`](https://github.com/gradio-app/daggr/commit/33ccb7470a482ee1b09fddf1d51de81b1e2c4a40) - Fix gr.Image not rendering with initial value or None input.  Thanks @abidlabs!\n- [#68](https://github.com/gradio-app/daggr/pull/68) [`4b76dca`](https://github.com/gradio-app/daggr/commit/4b76dca815e2802b70ac03bb95fb03f21d81a8fa) - Add dependency hash tracking for upstream Spaces and models.  Thanks @abidlabs!\n\n## 0.6.0\n\n### Features\n\n- [#54](https://github.com/gradio-app/daggr/pull/54) [`c1abb26`](https://github.com/gradio-app/daggr/commit/c1abb260b254af6ca2060292232049ea89f0f944) - Fix cache.  Thanks @abidlabs!\n- [#56](https://github.com/gradio-app/daggr/pull/56) [`6e3dfc0`](https://github.com/gradio-app/daggr/commit/6e3dfc0a585b673adb77bb11ab1dcfd80d01da5a) - Add paste from clipboard button to Image component.  Thanks @abidlabs!\n- [#57](https://github.com/gradio-app/daggr/pull/57) [`76855ba`](https://github.com/gradio-app/daggr/commit/76855ba967e3f3132e8ec0590ae037d3151af310) - Fix dropdown options being clipped inside node.  Thanks @abidlabs!\n- [#58](https://github.com/gradio-app/daggr/pull/58) [`eb52b72`](https://github.com/gradio-app/daggr/commit/eb52b725b17d277e85f6eac1cc9d07f8068b011b) - Add theme support to daggr.  Thanks @abidlabs!\n- [#59](https://github.com/gradio-app/daggr/pull/59) [`78189a4`](https://github.com/gradio-app/daggr/commit/78189a4163b4041c814e52110b65754dc4dbf863) - Add run mode dropdown to control node execution scope.  Thanks @abidlabs!\n- [#39](https://github.com/gradio-app/daggr/pull/39) [`e8792ad`](https://github.com/gradio-app/daggr/commit/e8792ad1b5818ff8d13660b0b156f329bbc1c33a) - feat: add --state-db-path CLI arg and DAGGR_DB_PATH env var support.  Thanks @leith-bartrich!\n\n## 0.5.4\n\n### Features\n\n- [#27](https://github.com/gradio-app/daggr/pull/27) [`3952b2c`](https://github.com/gradio-app/daggr/commit/3952b2ccf30e7d18994f23049c2a2e84b323cfd6) - changes.  Thanks @abidlabs!\n\n## 0.5.3\n\n### Features\n\n- [#19](https://github.com/gradio-app/daggr/pull/19) [`cd956fe`](https://github.com/gradio-app/daggr/commit/cd956fe29945bdfd31bbe76fcb80d3f9c97cc301) - Add daggr tag to deployed Spaces.  Thanks @gary149!\n\n## 0.5.2\n\n### Features\n\n- [#14](https://github.com/gradio-app/daggr/pull/14) [`3fa412d`](https://github.com/gradio-app/daggr/commit/3fa412d678988608d49d46d99d193a05469892d2) - Fixes.  Thanks @abidlabs!\n\n## 0.5.1\n\n### Features\n\n- [#11](https://github.com/gradio-app/daggr/pull/11) [`ce1d5f4`](https://github.com/gradio-app/daggr/commit/ce1d5f4deaac60d95d9a021b0aa057bc2941b018) - Fixes.  Thanks @abidlabs!\n- [#13](https://github.com/gradio-app/daggr/pull/13) [`3246921`](https://github.com/gradio-app/daggr/commit/32469213dad5fd29a7ac85938dffbd976e2c6643) - fixes.  Thanks @abidlabs!\n\n## 0.5.0\n\n### Features\n\n- [#8](https://github.com/gradio-app/daggr/pull/8) [`e480065`](https://github.com/gradio-app/daggr/commit/e480065dd058dbf19053a80956dbfc90cf3e3caf) - Improving security around executor and various bug fixes.  Thanks @abidlabs!\n\n## 0.4.0\n\n### Features\n\n- [#1](https://github.com/gradio-app/daggr/pull/1) [`23538c8`](https://github.com/gradio-app/daggr/commit/23538c884fb3f2d84bbe4bf14f475dc85fa17c79) - Refactor files, add Dialogue component, and implement fully working podcast example.  Thanks @abidlabs!\n\n## 0.1.0\n\nInitial release"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\n## Code Style\n\n- AVOID inline comments\n\n## Commands\n\n```bash\npip install -e .[dev]       # Install dev dependencies\npytest                      # Run tests\nruff check --fix --select I && ruff format  # Lint and format\n```\n\n## Architecture\n\nDAG-based workflow library connecting Gradio Spaces, ML models, and Python functions.\n\n### Core Files\n\n- **`graph.py`**: `Graph` class - holds nodes, edges, launches server\n- **`node.py`**: `GradioNode`, `FnNode`, `InferenceNode`, `InteractionNode`\n- **`port.py`**: `Port`, `ScatteredPort`, `GatheredPort` for parallel execution\n- **`edge.py`**: `Edge` class for node connections\n- **`executor.py`**: Executes workflow nodes\n- **`server.py`**: FastAPI server with WebSocket for real-time UI updates\n- **`state.py`**: Session state persistence with SQLite\n- **`frontend/`**: Svelte frontend for workflow visualization\n\n### Node Definition Pattern\n\nNodes use `inputs` and `outputs` dicts:\n\n```python\nnode = GradioNode(\n    space_or_url=\"owner/space\",\n    api_name=\"/endpoint\",\n    inputs={\n        \"param\": gr.Textbox(label=\"Label\"),  # UI input\n        \"other\": other_node.output_port,      # Port connection\n        \"fixed\": \"constant_value\",            # Fixed value\n    },\n    outputs={\n        \"result\": gr.Audio(label=\"Result\"),\n        \"hidden\": gr.Text(visible=False),     # Hidden output\n    },\n)\n```\n\n### Key Patterns\n\n1. **Port access**: `node.port_name` returns a `Port` for connections\n2. **Graph creation**: `Graph(nodes=[...])` auto-wires from port connections in inputs\n3. **Internal attrs**: Use `_` prefix (`_name`, `_fn`) to avoid port name conflicts\n\n## Issue Workflow\n\n1. Create branch: `git checkout -b fix-issue-NUMBER`\n2. Implement fix\n3. Run: `pytest && ruff check --fix --select I && ruff format`\n4. Do not commit - leave for user\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThank you for your interest in contributing! This document provides guidelines and information for contributing to the project.\n\n## Contribution Guidelines\n\nWe welcome contributions that:\n\n- Improve or enhance core functionality\n- Fix bugs in existing features\n- Add essential features that align with the project's goals\n\n## Development Setup\n\n1. Fork and clone the repository\n2. Install the package with development dependencies\n\n   ```bash\n   pip install -e \".[dev]\"\n   ```\n\n3. Run tests before submitting changes:\n\n   ```bash\n   python -m pytest\n   ```\n\n4. Build the frontend:\n   The project includes a Svelte-based frontend that must be built for the app to function correctly (requires Node 24+ and npm 11+).\n\n   ```bash\n   cd daggr/frontend\n   npm install\n   npm run build\n   cd ../..\n   ```\n\n5. Format your code using Ruff:\n\n   ```bash\n   ruff check --fix --select I && ruff format\n   ```\n\n## Pull Request Process\n\n1. Ensure your code passes all tests\n2. Format your code using Ruff\n3. Update documentation if necessary\n4. Submit a pull request with a clear description of your changes\n\nThank you for contributing!\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 Your Name\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE. "
  },
  {
    "path": "MANIFEST.in",
    "content": "include LICENSE\ninclude README.md\ninclude daggr/package.json\nrecursive-include daggr *.py *.pyi\nrecursive-include daggr/frontend/dist *\nrecursive-include daggr/assets *\nprune daggr/canvas-component\nprune daggr/frontend/node_modules\nprune daggr/frontend/src\n"
  },
  {
    "path": "README.md",
    "content": "<h3 align=\"center\">\n  <div style=\"display:flex;flex-direction:row;\">\n    <picture>\n      <source media=\"(prefers-color-scheme: dark)\" srcset=\"daggr/assets/logo_dark.png\">\n      <source media=\"(prefers-color-scheme: light)\" srcset=\"daggr/assets/logo_light.png\">\n      <img width=\"75%\" alt=\"daggr Logo\" src=\"daggr/assets/logo_light.png\">\n    </picture>\n    <p>DAG-based Gradio workflows!</p>\n  </div>\n</h3>\n\n`daggr` is a Python library for building AI workflows that connect [Gradio](https://github.com/gradio-app/gradio) apps, ML models (through [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers/en/index)), and custom Python functions. It automatically generates a visual canvas for your workflow allowing you to inspect intermediate outputs, rerun any step any number of times, and preserves state for complex or long-running workflows. Daggr also tracks provenance: when you browse through previous results, it automatically restores the exact inputs that produced each output, and visually indicates which parts of your workflow are stale.\n\n\n\n<img width=\"1462\" height=\"508\" alt=\"Screenshot 2026-01-26 at 1 01 58 PM\" src=\"https://github.com/user-attachments/assets/b751abd8-e143-4882-817b-036fb66a6d92\" />\n\n\n## Installation\n\n```bash\npip install daggr\n```\n\n(requires Python 3.10 or higher).\n\n## Quick Start\n\nAfter installing `daggr`, create a new Python file, say `app.py`, and paste this code:\n\n```python\nimport random\n\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\nglm_image = GradioNode(\n    \"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": gr.Textbox(  # An input node is created for the prompt\n            label=\"Prompt\",\n            value=\"A cheetah in the grassy savanna.\",\n            lines=3,\n        ),\n        \"height\": 1024,  # Fixed value (does not appear in the canvas)\n        \"width\": 1024,  # Fixed value (does not appear in the canvas)\n        \"seed\": random.random,  # Functions are rerun every time the workflow is run (not shown in the canvas)\n    },\n    outputs={\n        \"image\": gr.Image(\n            label=\"Image\"  # Display original image\n        ),\n    },\n)\n\nbackground_remover = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/image\",\n    inputs={\n        \"image\": glm_image.image,\n    },\n    postprocess=lambda _, final: final,\n    outputs={\n        \"image\": gr.Image(label=\"Final Image\"),  # Display only final image\n    },\n)\n\ngraph = Graph(\n    name=\"Transparent Background Image Generator\", nodes=[glm_image, background_remover]\n)\n\ngraph.launch()\n```\n\nRun `daggr app.py` to start the app with hot reloading (or `python app.py` for standard execution). You should see a Daggr app like the one shown above that you can use to generate images with a transparent background!\n\n\n\n## When to (Not) Use Daggr\n\nUse Daggr when:\n* You want to define an AI workflow in Python involving Gradio Spaces, inference providers, or custom functions\n* The workflow is complex enough that inspecting intermediate outputs or rerunning individual steps is useful\n* You need a fixed pipeline that you or others can run with different inputs\n* You want to explore variations: generate multiple outputs, compare them, and always know exactly what inputs produced each result\n\n**Why not... ComfyUI?** ComfyUI is a visual node editor where you build workflows by dragging and connecting nodes. Daggr takes a code-first approach: you define workflows in Python and the visual canvas is generated automatically. If you prefer writing code over visual editing, Daggr may be a better fit. In addition, Daggr works with Gradio Spaces and Hugging Face models directly, no need for specialized nodes.\n\n**Why not... Airflow/Prefect?** Daggr was inspired by Airflow/Prefect, but whereas the focus of these orchestration platforms is scheduling, monitoring, and managing pipelines at scale, Daggr is built for interactive AI/ML workflows with real-time visual feedback and immediate execution, making it ideal for prototyping, demos, and workflows where you want to inspect intermediate outputs and rerun individual steps on the fly.\n\n**Why not... Gradio?** Gradio creates web UIs for individual ML models and demos. While complex workflows can be built in Gradio, they often fail in ways that are hard to debug when using the Gradio app. Daggr tries to provide a transparent, easily-inspectable way to chain multiple Gradio apps, custom Python functions, and inference providers through a visual canvas.\n\nDon't use Daggr when:\n* You need a simple UI for a single model or function - consider using Gradio directly\n* You want a node-based editor for building workflows visually - consider using  ComfyUI instead\n\n## How It Works\n\nA Daggr workflow consists of **nodes** connected in a directed graph. Each node represents a computation: a Gradio Space API call, an inference call to a model, or a Python function.\n\nEach node has **input ports** and **output ports**, which correspond to the node's parameters and return values. Ports are how data flows between nodes.\n\n**Input ports** can be connected to:\n- A previous node's output port → creates an edge, data flows automatically\n- A Gradio component → creates a standalone input in the UI\n- A fixed value → passed directly, doesn't appear in UI\n- A `Callable` → called each time the node runs (useful for random seeds)\n\n**Output ports** can be:\n- A Gradio component → displays the output in the node's card\n- `None` → output not displayed in the node's card but port can still connect to downstream nodes\n\n### Node Types\n\n#### `GradioNode`\n\nCalls a Gradio Space API endpoint. Use this to connect to any Gradio app on Hugging Face Spaces or running locally.\n\n```python\nfrom daggr import GradioNode\nimport gradio as gr\n\nimage_gen = GradioNode(\n    space_or_url=\"black-forest-labs/FLUX.1-schnell\",  # HF Space ID or URL\n    api_name=\"/infer\",                                 # API endpoint name\n    inputs={\n        \"prompt\": gr.Textbox(label=\"Prompt\"),          # Creates UI input\n        \"seed\": 42,                                    # Fixed value\n        \"width\": 1024,\n        \"height\": 1024,\n    },\n    outputs={\n        \"image\": gr.Image(label=\"Generated Image\"),   # Display in node card\n    },\n)\n```\n\n**Finding the right inputs:** To find what parameters a GradioNode expects, go to the Gradio Space and click \"Use via API\" at the bottom of the page. This shows you the API endpoints and their parameters. For example, if the API page shows:\n\n```python\nfrom gradio_client import Client\n\nclient = Client(\"black-forest-labs/FLUX.1-schnell\")\nresult = client.predict(\n    prompt=\"Hello!!\",\n    seed=0,\n    randomize_seed=True,\n    width=1024,\n    height=1024,\n    num_inference_steps=4,\n    api_name=\"/infer\"\n)\n```\n\nThen your GradioNode inputs should use the same parameter names: `prompt`, `seed`, `randomize_seed`, `width`, `height`, `num_inference_steps`.\n\n**Outputs:** Output port names can be anything you choose—they simply map to the return values of the API endpoint in order. If an endpoint returns `(image, seed)`, you might define:\n\n```python\noutputs={\n    \"generated_image\": gr.Image(),  # Maps to first return value\n    \"used_seed\": gr.Number(),       # Maps to second return value\n}\n```\n\n#### `FnNode`\n\nRuns a Python function. Input ports are automatically discovered from the function signature.\n\n```python\nfrom daggr import FnNode\nimport gradio as gr\n\ndef summarize(text: str, max_words: int = 100) -> str:\n    words = text.split()[:max_words]\n    return \" \".join(words) + \"...\"\n\nsummarizer = FnNode(\n    fn=summarize,\n    inputs={\n        \"text\": gr.Textbox(label=\"Text to Summarize\", lines=5),\n        \"max_words\": gr.Slider(minimum=10, maximum=500, value=100, label=\"Max Words\"),\n    },\n    outputs={\n        \"summary\": gr.Textbox(label=\"Summary\"),\n    },\n)\n```\n\n**Inputs:** Keys in the `inputs` dict must match the function's parameter names. If you don't specify an input, it uses the function's default value (if available).\n\n**Outputs:** Return values are mapped to output ports in the same order they are defined in the `outputs` dict—just like GradioNode. For a single output, simply return the value. For multiple outputs, return a tuple:\n\n```python\ndef process(text: str) -> tuple[str, int]:\n    return text.upper(), len(text)\n\nnode = FnNode(\n    fn=process,\n    inputs={\"text\": gr.Textbox()},\n    outputs={\n        \"uppercase\": gr.Textbox(),  # First return value\n        \"length\": gr.Number(),       # Second return value\n    },\n)\n```\n\nNote: If you return a dict or list, it will be treated as a single value (mapped to the first output port), not as a mapping to output ports.\n\n**Concurrency:** By default, FnNodes execute sequentially (one at a time per user session) to prevent resource contention from concurrent function calls. If your function is safe to run in parallel, you can enable concurrent execution:\n\n```python\n# Allow this node to run in parallel with other nodes\nnode = FnNode(my_func, concurrent=True)\n\n# Share a resource limit with other nodes (e.g., GPU memory)\ngpu_node_1 = FnNode(process_image, concurrency_group=\"gpu\", max_concurrent=2)\ngpu_node_2 = FnNode(enhance_image, concurrency_group=\"gpu\", max_concurrent=2)\n```\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `concurrent` | `False` | If `True`, allow parallel execution |\n| `concurrency_group` | `None` | Name of a group sharing a concurrency limit |\n| `max_concurrent` | `1` | Max parallel executions in the group |\n\n> **Tip:** When possible, prefer `GradioNode` or `InferenceNode` over `FnNode`. These nodes automatically run concurrently (they're external API calls), and your Hugging Face token is automatically passed through for ZeroGPU quota tracking, private Spaces access, and gated model access.\n\n#### `InferenceNode`\n\nCalls a model via [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers/en/index). This lets you use models hosted on the Hugging Face Hub without downloading them.\n\n```python\nfrom daggr import InferenceNode\nimport gradio as gr\n\nllm = InferenceNode(\n    model=\"meta-llama/Llama-3.1-8B-Instruct\",\n    inputs={\n        \"prompt\": gr.Textbox(label=\"Prompt\", lines=3),\n    },\n    outputs={\n        \"response\": gr.Textbox(label=\"Response\"),\n    },\n)\n```\n\n**Inputs:** The expected inputs depend on the model's task type. For text generation models, use `prompt`. For other tasks, check the model's documentation on the Hub.\n\n**Outputs:** Like other nodes, output names are arbitrary and map to return values in order.\n\n> **Tip:** `InferenceNode` and `GradioNode` automatically run concurrently and pass your HF token for ZeroGPU, private Spaces, and gated models. Prefer these over `FnNode` when possible.\n\n### Preprocessing and Postprocessing\n\n`GradioNode`, `FnNode`, and `InferenceNode` all support optional `preprocess` and `postprocess` hooks that transform data on the way in and out of a node.\n\n**`preprocess`** receives the input dict and returns a modified dict before the node executes. This is useful when an upstream node outputs data in a different format than the downstream node expects:\n\n```python\ndef fix_image_input(inputs):\n    img = inputs.get(\"image\")\n    if isinstance(img, dict) and \"path\" in img:\n        inputs[\"image\"] = img[\"path\"]\n    return inputs\n\ndescriber = GradioNode(\n    \"vikhyatk/moondream2\",\n    api_name=\"/answer_question\",\n    preprocess=fix_image_input,\n    inputs={\"image\": image_gen.result, \"prompt\": \"Describe this image.\"},\n    outputs={\"description\": gr.Textbox()},\n)\n```\n\n**`postprocess`** receives the raw return values from the node and lets you reshape them before they are mapped to output ports. If the node returns multiple values (a tuple), each value is passed as a separate argument. This is essential when working with Spaces that return extra values you don't need:\n\n```python\nbackground_remover = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/image\",\n    inputs={\"image\": some_node.image},\n    postprocess=lambda original, final: final,  # Space returns (original, processed); keep only processed\n    outputs={\"image\": gr.Image(label=\"Result\")},\n)\n```\n\nAnother common pattern is extracting a specific item from a complex return value:\n\n```python\nimage_gen = GradioNode(\n    \"multimodalart/stable-cascade\",\n    api_name=\"/run\",\n    inputs={...},\n    postprocess=lambda images, seed_used, seed_number: images[0][\"image\"],  # Extract first image\n    outputs={\"image\": gr.Image(label=\"Generated Image\")},\n)\n```\n\n### File Handling\n\n> **Key difference from Gradio:** In daggr, all file-based data (images, audio, video, 3D models) is passed between nodes as **file path strings**. Gradio's `type` parameter (e.g., `Image(type=\"numpy\")`) is ignored — daggr does not convert files to numpy arrays, PIL images, or any other in-memory format.\n\nThis means:\n- **Inputs** to your node arrive as file path strings (e.g., `\"/tmp/daggr/abc123.png\"`)\n- **Outputs** from your node should be file path strings pointing to a file on disk\n\nIf your node expects a different format, use `preprocess` to convert file paths on the way in, and `postprocess` to convert back to file paths on the way out. This works with all node types:\n\n```python\nfrom PIL import Image\n\ndef load_image(inputs):\n    inputs[\"image\"] = Image.open(inputs[\"image\"])\n    return inputs\n\ndef save_image(result):\n    out_path = \"/tmp/processed.png\"\n    result.save(out_path)\n    return out_path\n\nnode = FnNode(\n    lambda image: image.rotate(90),\n    preprocess=load_image,\n    postprocess=save_image,\n    inputs={\"image\": gr.Image(label=\"Input\")},\n    outputs={\"output\": gr.Image(label=\"Rotated\")},\n)\n```\n\nFor audio:\n\n```python\nimport soundfile as sf\n\ndef load_audio(inputs):\n    data, sr = sf.read(inputs[\"audio\"])\n    inputs[\"audio\"] = (sr, data)\n    return inputs\n\ndef save_audio(result):\n    sr, data = result\n    out_path = \"/tmp/processed.wav\"\n    sf.write(out_path, data, sr)\n    return out_path\n```\n#### `InputNode`\n\nThe `InputNode` allows you to group multiple Gradio input components (such as `gr.Textbox`, `gr.Slider`, or `gr.Dropdown`) into a single, organized block on the canvas. This is particularly useful for workflows with many parameters, preventing the UI from becoming cluttered with numerous individual input nodes.\n\n**How it works:** Each key defined in the `ports` dictionary automatically becomes a distinct **output port** on the node. You can then wire these ports to downstream processing nodes.\n\n```python\nimport gradio as gr\nfrom daggr import InputNode, GradioNode, Graph\n\nparameters = InputNode(\n    name=\"Parameters\",\n    ports={\n        \"prompt\": gr.Textbox(\n            label=\"Prompt\",\n            value=\"A cheetah in the grassy savanna.\",\n            lines=3,\n        ),\n        \"height\": gr.Slider(\n            label=\"Height\", value=1024, minimum=512, maximum=2048, step=128\n        ),\n        \"width\": gr.Slider(\n            label=\"Width\", value=1024, minimum=512, maximum=2048, step=128\n        )       \n    },\n)\n\nglm_image = GradioNode(\n    \"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": parameters.prompt,\n        \"height\": parameters.height,\n        \"width\": parameters.width,\n    },    \n    outputs={\n        \"image\": gr.Image(label=\"Image\"),\n    },\n)\n\ngraph = Graph(name=\"Grouped Inputs Demo\", nodes=[glm_image])\ngraph.launch()\n```\n\n### Node Concurrency\n\nDifferent node types have different concurrency behaviors:\n\n| Node Type | Concurrency | Why |\n|-----------|-------------|-----|\n| `GradioNode` | **Concurrent** | External API calls—safe to parallelize |\n| `InferenceNode` | **Concurrent** | External API calls—safe to parallelize |\n| `FnNode` | **Sequential** (default) | Local Python code may have resource constraints |\n\n**Why sequential by default for FnNode?** Local Python functions often:\n- Access shared resources (files, databases, GPU memory)\n- Use libraries that aren't thread-safe\n- Consume significant CPU/memory\n\nBy running FnNodes sequentially per session, daggr prevents race conditions and resource contention. If your function is safe to run in parallel, opt in with `concurrent=True`.\n\n**Concurrency groups** let multiple nodes share a resource limit:\n\n```python\n# Both nodes share GPU—at most 2 concurrent executions total\nupscale = FnNode(upscale_image, concurrency_group=\"gpu\", max_concurrent=2)\nenhance = FnNode(enhance_image, concurrency_group=\"gpu\", max_concurrent=2)\n```\n\n### Testing Nodes\n\nYou can test-run any node in isolation using the `.test()` method:\n\n```python\ntts = GradioNode(\"mrfakename/MeloTTS\", api_name=\"/synthesize\", ...)\nresult = tts.test(text=\"Hello world\", speaker=\"EN-US\")\n# Returns: {\"audio\": \"/path/to/audio.wav\"}\n```\n\nIf called without arguments, `.test()` auto-generates example values using each input component's `.example_value()` method:\n\n```python\nresult = tts.test()  # Uses gr.Textbox().example_value(), etc.\n```\n\nThis is useful for quickly checking what format a node returns without wiring up a full workflow.\n\n### Input Types\n\nEach node's `inputs` dict accepts four types of values:\n\n| Type | Example | Result |\n|------|---------|--------|\n| **Gradio component** | `gr.Textbox(label=\"Topic\")` | Creates UI input |\n| **Port reference** | `other_node.output_name` | Connects nodes |\n| **Fixed value** | `\"Auto\"` or `42` | Constant, no UI |\n| **Callable** | `random.random` | Called each run, no UI |\n\n### Output Types\n\nEach node's `outputs` dict accepts two types of values:\n\n| Type | Example | Result |\n|------|---------|--------|\n| **Gradio component** | `gr.Image(label=\"Result\")` | Displays output in node card |\n| **None** | `None` | Hidden, but can connect to downstream nodes |\n\n### Scatter / Gather (experimental)\n\nWhen a node outputs a list and you want to process each item individually, use `.each` to scatter and `.all()` to gather:\n\n```python\nscript = FnNode(fn=generate_script, inputs={...}, outputs={\"lines\": gr.JSON()})\n\ntts = FnNode(\n    fn=text_to_speech,\n    inputs={\n        \"text\": script.lines.each[\"text\"],      # Scatter: run once per item\n        \"speaker\": script.lines.each[\"speaker\"],\n    },\n    outputs={\"audio\": gr.Audio()},\n)\n\nfinal = FnNode(\n    fn=combine_audio,\n    inputs={\"audio_files\": tts.audio.all()},    # Gather: collect all outputs\n    outputs={\"audio\": gr.Audio()},\n)\n```\n\n### Choice Nodes (experimental)\n\nSometimes you want to offer multiple alternatives for the same step in your workflow—for example, two different TTS providers or image generators. Use the `|` operator to create a **choice node** that lets users switch between variants in the UI:\n\n```python\nhost_voice = GradioNode(\n    space_or_url=\"abidlabs/tts\",\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(label=\"Host Voice\"),\n        \"language\": \"Auto\",\n        \"text\": \"Hi! I'm the host!\",\n    },\n    outputs={\"audio\": gr.Audio(label=\"Host Voice\")},\n) | GradioNode(\n    space_or_url=\"mrfakename/E2-F5-TTS\",\n    api_name=\"/basic_tts\",\n    inputs={\n        \"ref_audio_input\": gr.Audio(label=\"Reference Audio\"),\n        \"gen_text_input\": gr.Textbox(label=\"Text to Generate\"),\n    },\n    outputs={\"audio\": gr.Audio(label=\"Host Voice\")},\n)\n\n# Downstream nodes connect to host_voice.audio regardless of which variant is selected\ndialogue = FnNode(\n    fn=generate_dialogue,\n    inputs={\"host_voice\": host_voice.audio, ...},\n    ...\n)\n```\n\nIn the canvas, choice nodes display an accordion UI where you can:\n- See all available variants\n- Click to select which variant to use\n- View the selected variant's input components\n\nThe selected variant is persisted per sheet, so your choice is remembered across page refreshes. All variants must have the same output ports (so downstream connections work regardless of selection), but they can have different input ports.\n\n## Putting It Together: A Mock Podcast Generator\n\n```python\nimport gradio as gr\nfrom daggr import FnNode, GradioNode, Graph\n\n# Generate voice profiles\nhost_voice = GradioNode(\n    space_or_url=\"abidlabs/tts\",\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(label=\"Host Voice\", value=\"Deep British voice...\"),\n        \"language\": \"Auto\",\n        \"text\": \"Hi! I'm the host.\",\n    },\n    outputs={\"audio\": gr.Audio(label=\"Host Voice\")},\n)\n\nguest_voice = GradioNode(\n    space_or_url=\"abidlabs/tts\",\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(label=\"Guest Voice\", value=\"Friendly American voice...\"),\n        \"language\": \"Auto\",\n        \"text\": \"Hi! I'm the guest.\",\n    },\n    outputs={\"audio\": gr.Audio(label=\"Guest Voice\")},\n)\n\n# Generate dialogue (would be an LLM call in production)\ndef generate_dialogue(topic: str, host_voice: str, guest_voice: str) -> tuple[list, str]:\n    dialogue = [\n        {\"voice\": host_voice, \"text\": \"Hello, how are you?\"},\n        {\"voice\": guest_voice, \"text\": \"I'm great, thanks!\"},\n    ]\n    html = \"<b>Host:</b> Hello!<br><b>Guest:</b> I'm great!\"\n    return dialogue, html  # Returns tuple: first value -> \"json\", second -> \"html\"\n\ndialogue = FnNode(\n    fn=generate_dialogue,\n    inputs={\n        \"topic\": gr.Textbox(label=\"Topic\", value=\"AI\"),\n        \"host_voice\": host_voice.audio,\n        \"guest_voice\": guest_voice.audio,\n    },\n    outputs={\n        \"json\": gr.JSON(visible=False),  # Maps to first return value\n        \"html\": gr.HTML(label=\"Script\"),  # Maps to second return value\n    },\n)\n\n# Generate audio for each line (scatter)\ndef text_to_speech(text: str, audio: str) -> str:\n    return audio  # Would call TTS model in production\n\nsamples = FnNode(\n    fn=text_to_speech,\n    inputs={\n        \"text\": dialogue.json.each[\"text\"],\n        \"audio\": dialogue.json.each[\"voice\"],\n    },\n    outputs={\"audio\": gr.Audio(label=\"Sample\")},\n)\n\n# Combine all audio (gather)\ndef combine_audio(audio_files: list[str]) -> str:\n    from pydub import AudioSegment\n    combined = AudioSegment.empty()\n    for path in audio_files:\n        combined += AudioSegment.from_file(path)\n    combined.export(\"output.mp3\", format=\"mp3\")\n    return \"output.mp3\"\n\nfinal = FnNode(\n    fn=combine_audio,\n    inputs={\"audio_files\": samples.audio.all()},\n    outputs={\"audio\": gr.Audio(label=\"Full Podcast\")},\n)\n\ngraph = Graph(name=\"Podcast Generator\", nodes=[host_voice, guest_voice, dialogue, samples, final])\ngraph.launch()\n```\n\n## Sharing and Hosting\n\nCreate a public URL to share your workflow with others:\n\n```python\ngraph.launch(share=True)\n```\n\nThis generates a temporary public URL (expires in 1 week) using Gradio's tunneling infrastructure.\n\n### Deploying to Hugging Face Spaces\n\nFor permanent hosting, use `daggr deploy` to deploy your app to [Hugging Face Spaces](https://huggingface.co/spaces):\n\n```bash\ndaggr deploy my_app.py\n```\n\nAssuming you are logged in locally with your [Hugging Face token](https://huggingface.co/settings/tokens), this command:\n1. Extracts the Graph from your script\n2. Creates a Space named after your Graph (e.g., \"Podcast Generator\" → `podcast-generator`)\n3. Uploads your script and dependencies\n4. Configures the Space with the Gradio SDK\n\n#### Deploy Options\n\n```bash\n# Custom Space name\ndaggr deploy my_app.py --name my-custom-space\n\n# Deploy to an organization\ndaggr deploy my_app.py --org huggingface\n\n# Private Space with GPU\ndaggr deploy my_app.py --private --hardware t4-small\n\n# Add secrets (e.g., API keys)\ndaggr deploy my_app.py --secret HF_TOKEN=xxx --secret OPENAI_KEY=yyy\n\n# Preview without deploying\ndaggr deploy my_app.py --dry-run\n```\n\n| Option | Short | Description |\n|--------|-------|-------------|\n| `--name` | `-n` | Space name (default: derived from Graph name) |\n| `--title` | `-t` | Display title (default: Graph name) |\n| `--org` | `-o` | Organization to deploy under |\n| `--private` | `-p` | Make the Space private |\n| `--hardware` | | Hardware tier: `cpu-basic`, `cpu-upgrade`, `t4-small`, `t4-medium`, `a10g-small`, etc. |\n| `--secret` | `-s` | Add secrets (repeatable) |\n| `--requirements` | `-r` | Custom requirements.txt path |\n| `--dry-run` | | Preview what would be deployed |\n\nThe deploy command automatically:\n- Detects local Python imports and includes them\n- Uses existing `requirements.txt` if present, or generates one with `daggr`\n- Renames your script to `app.py` (HF Spaces convention)\n- Generates the required `README.md` with Space metadata\n\n### Manual Deployment\n\nYou can also deploy manually by creating a new Space with the Gradio SDK, adding your workflow code to `app.py`, and including `daggr` in your `requirements.txt`.\n\nDaggr automatically reads the `GRADIO_SERVER_NAME` and `GRADIO_SERVER_PORT` environment variables, which Hugging Face Spaces sets automatically for Gradio apps. This means your daggr app will work on Spaces without any additional configuration.\n\n## Persistence and Sheets\n\nDaggr automatically saves your workflow state—input values, node results, and canvas position—so you can pick up where you left off after a page reload.\n\n### Sheets\n\n**Sheets** are like separate workspaces within a single Daggr app. Each sheet has its own:\n- Input values for all nodes\n- Cached results from previous runs  \n- Canvas zoom and pan position\n\nUse sheets to work on multiple projects within the same workflow. For example, in a podcast generator app, each sheet could represent a different podcast episode you're working on.\n\nThe sheet selector appears in the title bar. Click to switch between sheets, create new ones, rename them (double-click), or delete them.\n\n### Result History and Provenance Tracking\n\nEvery time a node runs, Daggr saves not just the output, but also a snapshot of all input values at that moment. This enables powerful exploratory workflows:\n\n**Browsing previous results**: Use the `‹` and `›` arrows in the node footer to navigate through all cached results for that node (shown as \"1/3\", \"2/3\", etc.).\n\n**Automatic input restoration**: When you select a previous result, Daggr automatically restores the input values that produced it. This means you can:\n\n1. Generate multiple variations by running a node several times with different inputs\n2. Browse through your results to find the best one\n3. When you select a result, see exactly what inputs created it\n4. Continue your workflow from that point with all the original context intact\n\n**Cascading restoration**: When you toggle through results on a node, Daggr also automatically selects the matching result on downstream nodes (if one exists). For example, if you generated 3 images and removed the background from 2 of them, selecting image #1 will automatically show background-removal result #1.\n\n#### Visual Staleness Indicators\n\nDaggr uses edge colors to show you which parts of your workflow are up-to-date:\n\n| Edge Color | Meaning |\n|------------|---------|\n| **Orange** | Fresh—the downstream node ran with this exact upstream value |\n| **Gray** | Stale—the upstream value has changed, or the downstream hasn't run yet |\n\n<img width=\"957\" height=\"418\" alt=\"image\" src=\"https://github.com/user-attachments/assets/4acd0ec2-9561-44fc-8a40-d09ab972d717\" />\n\n<img width=\"957\" height=\"418\" alt=\"image\" src=\"https://github.com/user-attachments/assets/683e7cbe-779f-44a9-9401-a6aafc57a936\" />\n\n\nEdges are stale when:\n- You edit an input value (e.g., change a text prompt)\n- You select a different cached result on an upstream node\n- A downstream node hasn't been run yet\n\nThis visual feedback helps you understand at a glance which results are current and which need to be re-run. It's especially useful in long workflows where you might forget which steps you've already executed with your current inputs.\n\n**Example workflow:**\n1. Generate an image with prompt \"A cheetah in the savanna\" → edge turns orange\n2. Edit the prompt to \"A lion in the jungle\" → edge turns gray (stale)\n3. Re-run the image generation → edge turns orange again\n4. Run the background removal node → that edge also turns orange\n\nThis provenance tracking is particularly valuable for creative workflows where you're exploring variations and want to always know exactly what inputs produced each output.\n\n### How Persistence Works\n\n| Environment | User Status | Persistence |\n|-------------|-------------|-------------|\n| **Local** | Not logged in | ✅ Saved as \"local\" user |\n| **Local** | HF logged in | ✅ Saved under your HF username |\n\nWhen running locally, your data is stored in a SQLite database at `~/.cache/huggingface/daggr/sessions.db`.\n\n### The `persist_key` Parameter\n\nBy default, the `persist_key` is derived from your graph's `name`:\n\n```python\nGraph(name=\"My Podcast Generator\")  # persist_key = \"my_podcast_generator\"\n```\n\nIf you later rename your app but want to keep the existing saved data, set `persist_key` explicitly:\n\n```python\nGraph(name=\"Podcast Generator v2\", persist_key=\"my_podcast_generator\")\n```\n\n### Disabling Persistence\n\nFor scratch workflows or demos where you don't want data saved:\n\n```python\nGraph(name=\"Quick Demo\", persist_key=False)\n```\n\nThis disables all persistence—no sheets UI, no saved state.\n\n## Hugging Face Authentication\n\nDaggr automatically uses your local Hugging Face token for both `GradioNode` and `InferenceNode`. This enables:\n\n- **ZeroGPU quota tracking**: Your HF token is sent to Gradio Spaces running on ZeroGPU, so your usage is tracked against your account's quota\n- **Private Spaces access**: Connect to private Gradio Spaces you have access to\n- **Gated models**: Use gated models on Hugging Face that require accepting terms of service\n\nTo log in with your Hugging Face account:\n\n```bash\npip install huggingface_hub\nhf auth login\n```\n\nYou'll be prompted to enter your token, which you can find at https://huggingface.co/settings/tokens. \n\nOnce logged in, the token is saved locally and daggr will automatically use it for all `GradioNode` and `InferenceNode` calls—no additional configuration needed.\n\nAlternatively, you can set the `HF_TOKEN` environment variable directly:\n\n```bash\nexport HF_TOKEN=hf_xxxxx\n```\n\n## LLM-Friendly Error Messages\n\nDaggr is designed to be LLM-friendly, making it easy for AI coding assistants to generate and debug workflows. To give your AI coding assistant context on how to use daggr, you can install the daggr skill:\n\n```bash\nnpx skills add gradio-app/daggr\n``` \n\n<img width=\"3444\" height=\"2342\" alt=\"image\" src=\"https://github.com/user-attachments/assets/b054d648-0f75-43a8-9335-1480d6bf9263\" />\n\n\nWhen you (or an LLM) make a mistake, Daggr provides detailed, actionable error messages with suggestions:\n\n**Invalid API endpoint:**\n```\nValueError: API endpoint '/infer' not found in 'hf-applications/background-removal'. \nAvailable endpoints: ['/image', '/text', '/png']. Did you mean '/image'?\n```\n\n**Typo in parameter name:**\n```\nValueError: Invalid parameter(s) {'promt'} for endpoint '/generate_image' in \n'hf-applications/Z-Image-Turbo'. Did you mean: 'promt' -> 'prompt'? \nValid parameters: {'width', 'height', 'seed', 'prompt'}\n```\n\n**Missing required parameter:**\n```\nValueError: Missing required parameter(s) {'prompt'} for endpoint '/generate_image' \nin 'hf-applications/Z-Image-Turbo'. These parameters have no default values.\n```\n\n**Invalid output port reference:**\n```\nValueError: Output port 'img' not found on node 'Z-Image-Turbo'. \nAvailable outputs: image. Did you mean 'image'?\n```\n\n**Invalid function parameter:**\n```\nValueError: Invalid input(s) {'toppic'} for function 'generate_dialogue'. \nDid you mean: 'toppic' -> 'topic'? Valid parameters: {'topic', 'host_voice', 'guest_voice'}\n```\n\n**Invalid model name:**\n```\nValueError: Model 'meta-llama/nonexistent-model' not found on Hugging Face Hub. \nPlease check the model name is correct (format: 'username/model-name').\n```\n\nThese errors make it easy for LLMs to understand what went wrong and fix the generated code automatically, enabling a smoother AI-assisted development experience.\n\n### Discovering Output Formats\n\nWhen building workflows, LLMs can use `.test()` to discover a node's actual output format:\n\n```python\n# LLM wants to understand what whisper returns\nwhisper = InferenceNode(\"openai/whisper-large-v3\", inputs={\"audio\": gr.Audio()})\nresult = whisper.test(audio=\"sample.wav\")\n# Returns: {\"text\": \"Hello, how are you?\"}\n```\n\nThis helps LLMs:\n- Understand the structure of node outputs\n- Apply `postprocess` functions to extract specific values\n- Create intermediate `FnNode`s to transform data between nodes\n\nFor example, if a node returns multiple values but you only need one:\n\n```python\n# After discovering the output format with .test()\nbg_remover = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/image\",\n    inputs={\"image\": some_image.output},\n    postprocess=lambda original, final: final,  # Keep only the second output\n    outputs={\"image\": gr.Image()},\n)\n```\n\n## Running Locally\n\nWhile in our examples above, we've seen how Daggr works with remote Gradio Spaces and Hugging Face Inference Providers, it's also well-suited for completely local, offline workflows.\n\n### Automatic Local Execution\n\nThe easiest way to run a Space locally is to set `run_locally=True` on any `GradioNode`. Daggr will automatically clone the Space, install dependencies in an isolated virtual environment, and launch the Gradio app:\n\n```python\nfrom daggr import GradioNode, Graph\nimport gradio as gr\n\n# Automatically clone and run the Space locally\nbackground_remover = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/image\",\n    run_locally=True,  # Run locally instead of calling the remote API\n    inputs={\"image\": gr.Image(label=\"Input Image\")},\n    outputs={\"final_image\": gr.Image(label=\"Output\")},\n)\n\ngraph = Graph(name=\"Local Background Removal\", nodes=[background_remover])\ngraph.launch()\n```\n\nOn first run, daggr will:\n\n1. Clone the Space repository to `~/.cache/huggingface/daggr/spaces/`\n2. Create an isolated virtual environment with the Space's dependencies\n3. Launch the Gradio app on an available port\n4. Connect to it automatically\n\nSubsequent runs reuse the cached clone and venv, making startup much faster.\n\n### Graceful Fallback\n\nIf local execution fails (missing dependencies, GPU requirements, etc.), daggr automatically falls back to the remote API and prints helpful guidance:\n\n```\n⚠️  Local execution failed for 'owner/space-name'\nReason: Failed to install dependencies\nLogs: ~/.cache/huggingface/daggr/logs/owner_space-name_pip_install_2026-01-27.log\nFalling back to remote API...\n```\n\nTo disable fallback and see the full error (useful for debugging):\n\n```bash\nexport DAGGR_LOCAL_NO_FALLBACK=1\n```\n\n### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `DAGGR_LOCAL_TIMEOUT` | `120` | Seconds to wait for the app to start |\n| `DAGGR_LOCAL_VERBOSE` | `0` | Set to `1` to show app stdout/stderr |\n| `DAGGR_LOCAL_NO_FALLBACK` | `0` | Set to `1` to disable fallback to remote |\n| `DAGGR_UPDATE_SPACES` | `0` | Set to `1` to re-clone cached Spaces |\n| `DAGGR_DEPENDENCY_CHECK` | *(unset)* | `skip`, `update`, or `error` — controls upstream hash checking |\n| `GRADIO_SERVER_NAME` | `127.0.0.1` | Host to bind to. Set to `0.0.0.0` on HF Spaces |\n| `GRADIO_SERVER_PORT` | `7860` | Port to bind to |\n\n### Manual Local URL\n\nYou can also run a Gradio app yourself and point to it directly:\n\n```python\nfrom daggr import GradioNode, Graph\nimport gradio as gr\n\n# Connect to a Gradio app you're running locally\nlocal_model = GradioNode(\n    \"http://localhost:7860\",  # Local URL instead of Space ID\n    api_name=\"/predict\",\n    inputs={\"text\": gr.Textbox(label=\"Input\")},\n    outputs={\"result\": gr.Textbox(label=\"Output\")},\n)\n\ngraph = Graph(name=\"Local Workflow\", nodes=[local_model])\ngraph.launch()\n```\n\nThis approach lets you run your entire workflow offline, use custom or fine-tuned models, and avoid API rate limits.\n\n\n### API Access\n\nDaggr workflows can be called programmatically via REST API, making it easy to integrate workflows into other applications or run automated tests.\n\n#### Discovering the API Schema\n\nFirst, get the API schema to see available inputs and outputs:\n\n```bash\ncurl http://localhost:7860/api/schema\n```\n\nResponse:\n```json\n{\n  \"subgraphs\": [\n    {\n      \"id\": \"main\",\n      \"inputs\": [\n        {\"node\": \"image_gen\", \"port\": \"prompt\", \"type\": \"textbox\", \"id\": \"image_gen__prompt\"}\n      ],\n      \"outputs\": [\n        {\"node\": \"background_remover\", \"port\": \"image\", \"type\": \"image\"}\n      ]\n    }\n  ]\n}\n```\n\n#### Calling the Workflow\n\nExecute the entire workflow by POSTing inputs to `/api/call`:\n\n```bash\ncurl -X POST http://localhost:7860/api/call \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"inputs\": {\"image_gen__prompt\": \"A mountain landscape\"}}'\n```\n\nResponse:\n```json\n{\n  \"outputs\": {\n    \"background_remover\": {\n      \"image\": \"/file/path/to/output.png\"\n    }\n  }\n}\n```\n\nInput keys follow the format `{node_name}__{port_name}` (with spaces/dashes replaced by underscores).\n\n#### Disconnected Subgraphs\n\nIf your workflow has multiple disconnected subgraphs, use `/api/call/{subgraph_id}`:\n\n```bash\n# List available subgraphs\ncurl http://localhost:7860/api/schema\n\n# Call a specific subgraph\ncurl -X POST http://localhost:7860/api/call/subgraph_0 \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"inputs\": {...}}'\n```\n\n#### Python Example\n\n```python\nimport requests\n\n# Get schema\nschema = requests.get(\"http://localhost:7860/api/schema\").json()\n\n# Execute workflow\nresponse = requests.post(\n    \"http://localhost:7860/api/call\",\n    json={\"inputs\": {\"my_node__text\": \"Hello world\"}}\n)\noutputs = response.json()[\"outputs\"]\n```\n\n\n## Hot Reload Mode\n\nDuring development, you can use the `daggr` CLI to run your app with automatic hot reloading. When you make changes to your Python file or its dependencies, the app automatically restarts:\n\n```bash\ndaggr examples/01_quickstart.py\n```\n\nThis is much faster than manually stopping and restarting your app each time you make a change.\n\n### CLI Options\n\n```bash\ndaggr <script> [options]\n```\n\n| Option | Description |\n|--------|-------------|\n| `--host` | Host to bind to (default: `127.0.0.1`) |\n| `--port` | Port to bind to (default: `7860`) |\n| `--no-reload` | Disable auto-reload |\n| `--no-watch-daggr` | Don't watch daggr source for changes |\n\n### What Gets Watched\n\nBy default, the CLI watches for changes in:\n\n- **Your script file** and its directory\n- **Local imports** from your script\n- **The daggr source code** itself (useful when developing daggr)\n\nTo disable watching the daggr source (e.g., in production-like testing):\n\n```bash\ndaggr examples/01_quickstart.py --no-watch-daggr\n```\n\n### API Caching\n\nTo speed up reloads, daggr caches Gradio Space API info in `~/.cache/huggingface/daggr/`. This means:\n\n- **First run**: Connects to each Gradio Space to fetch API info (cached to disk)\n- **Subsequent reloads**: Loads from cache, no network calls needed\n\nIf you change a Space's API or encounter stale cache issues, clear the cache:\n\n```bash\nrm -rf ~/.cache/huggingface/daggr\n```\n\n### When to Use Hot Reload\n\nUse `daggr <script>` when you're actively developing and want instant feedback on changes.\n\nUse `python <script>` when you want the standard behavior (no file watching, direct execution).\n\n\n## Upstream Dependency Tracking\n\nWhen your workflow references external Gradio Spaces or Hugging Face models, those dependencies can change at any time—a Space author might update the model, change the API, or alter default behavior. This can silently break reproducibility: the same workflow with the same inputs may produce different results weeks later.\n\nTo address this, daggr tracks the commit SHA of every upstream Space and model the first time your app launches. On subsequent launches, daggr compares the cached SHA against the current version. If an upstream dependency has changed, you'll see a terminal warning:\n\n```\n  ⚠️  Upstream dependency changes detected:\n\n    • space 'mrfakename/MeloTTS' (node: MeloTTS)\n      cached:  a1b2c3d4e5f6\n      current: f6e5d4c3b2a1\n\n  How would you like to handle 'mrfakename/MeloTTS'?\n    [1] Duplicate the original version under your namespace (safer)\n    [2] Update to the latest version\n```\n\n**Option 1 (Spaces only)** downloads the Space at the exact commit you originally built against and re-uploads it under your Hugging Face namespace, so your workflow continues using the known-good version. This requires being logged in via `huggingface-cli login`.\n\n**Option 2** updates the cached hash to accept the new version.\n\nFor CI/CD or non-interactive environments, set the `DAGGR_DEPENDENCY_CHECK` environment variable:\n\n| Value | Behavior |\n|-------|----------|\n| `skip` | Skip all dependency checks |\n| `update` | Auto-accept upstream changes |\n| `error` | Fail if any dependency has changed |\n\nDependency hashes are stored in `~/.cache/huggingface/daggr/_dependency_hashes.json`.\n\n## Beta Status\n\n> [!WARNING]\n> Daggr is in active development. APIs may change between versions, and while we persist workflow state locally, data loss is possible during updates. We recommend not relying on daggr for production-critical workflows yet. Please [report issues](https://github.com/gradio-app/daggr/issues) if you encounter bugs!\n\n## Development\n\n```bash\npip install -e \".[dev]\"\nruff check --fix --select I && ruff format\n```\n\n## License\n\nMIT License\n"
  },
  {
    "path": "RELEASE.md",
    "content": "# Making a release\n\n> [!NOTE]\n> VERSION needs to be formatted following the `v{major}.{minor}.{patch}` convention. We need to follow this convention to be able to retrieve versioned scripts.\n\n## Major/Minor Release\n\n### 1. Ensure your local repository is up to date with the upstream repository\n\n```bash\ngit checkout main\ngit pull origin main\n```\n\n> [!WARNING]\n> Do not merge other pull requests into `main` until the release is done. This is to ensure that the release is stable and does not include any untested changes. Announce internally to other maintainers that you are doing a release and that they must not merge PRs until the release is done.\n\n### 2. Create a release branch from main\n\n```bash\ngit checkout -b release-v{major}.{minor}\n```\n\n### 3. Change the version in the following file\n\n- `daggr/package.json`:\n\n  ```diff\n  - \"version\": \"{major}.{minor}.0.dev0\"\n  + \"version\": \"{major}.{minor}.0\"\n  ```\n\n### 4. Commit and push these changes\n\n```shell\ngit add daggr/package.json\ngit commit -m 'Release: {major}.{minor}'\ngit push origin release-v{major}.{minor}\n```\n\n### 5. Create a pull request\n\nfrom `release-v{major}.{minor}` to `main`, named `Release: v{major}.{minor}`, wait for tests to pass, and request a review.\n\n### 6. Once the pull request is approved, merge it into `main`\n\nThis will automatically trigger the CI to publish the package to PyPI.\n\n### 7. Add a tag in git to mark the release\n\n```shell\ngit checkout main\ngit pull origin main\ngit tag -a v{major}.{minor}.0 -m 'Adds tag v{major}.{minor}.0 for PyPI'\ngit push origin v{major}.{minor}.0\n```\n\n### 8. Create a branch `v{major}.{minor}-release` for future patch releases\n\n```shell\ngit checkout -b v{major}.{minor}-release\ngit push origin v{major}.{minor}-release\n```\n\nThis ensures that future patch releases (`v{major}.{minor}.1`, `v{major}.{minor}.2`, etc.) can be made separately from `main`.\n\n### 9. Create a GitHub Release\n\n1. Go to the repo's releases section on GitHub.\n2. Click **Draft a new release**.\n3. Select the `v{major}.{minor}.0` tag you just created in step 7.\n4. Add a title (`v{major}.{minor}.0`) and a short description of what's new.\n5. Click **Publish Release**.\n\n### 10. Bump to dev version\n\n1. Create a branch `bump-dev-version-{major}.{minor+1}` from `main` and checkout to it.\n\n   ```shell\n   git checkout -b bump-dev-version-{major}.{minor+1}\n   ```\n\n2. Change the version in `daggr/package.json`\n\n   ```diff\n   - \"version\": \"{major}.{minor}.0\"\n   + \"version\": \"{major}.{minor+1}.0.dev0\"\n   ```\n\n3. Commit and push these changes\n\n   ```shell\n   git add daggr/package.json\n   git commit -m '⬆️ Bump dev version'\n   git push origin bump-dev-version-{major}.{minor+1}\n   ```\n\n4. Create a pull request from `bump-dev-version-{major}.{minor+1}` to `main`, named `⬆️ Bump dev version`, and request urgent review.\n\n5. Once the pull request is approved, merge it into `main`.\n\n6. The codebase is now ready for the next development cycle.\n\n## Making a patch release\n\n### 1. Ensure your local repository is up to date with the upstream repository\n\n```bash\ngit checkout v{major}.{minor}-release\ngit pull origin main\n```\n\n### 2. Cherry-pick the changes you want to include in the patch release\n\n```bash\ngit cherry-pick <commit-hash-0>\ngit cherry-pick <commit-hash-1>\n...\n```\n\n### 3. Change the version in the following files\n\n- `daggr/package.json`:\n\n  ```diff\n  - \"version\": \"{major}.{minor}.{patch-1}\"\n  + \"version\": \"{major}.{minor}.{patch}\"\n  ```\n\n### 4. Commit and push these changes\n\n```shell\ngit add daggr/package.json\ngit commit -m 'Release: {major}.{minor}.{patch}'\ngit push origin v{major}.{minor}-release\n```\n\n### 5. Wait for the CI to pass\n\nThis will automatically trigger the CI to publish the package to PyPI.\n\n### 6. Add a tag in git to mark the release\n\n```shell\ngit tag -a v{major}.{minor}.{patch} -m 'Adds tag v{major}.{minor}.{patch} for PyPI'\ngit push origin v{major}.{minor}.{patch}\n```\n\n### 7. Create a GitHub Release\n\n1. Go to the repo's releases section on GitHub.\n2. Click **Draft a new release**.\n3. Select the `v{major}.{minor}.{patch}` tag you just created in step 6.\n4. Add a title (`v{major}.{minor}.{patch}`) and a short description of what's new.\n5. Click **Publish Release**.\n"
  },
  {
    "path": "build_pypi.sh",
    "content": "#!/bin/bash\nset -e\n\ncd \"$(dirname ${0})\"\n\npython3 -m pip install build\nrm -rf dist/*\nrm -rf build/*\npython3 -m build -w\n"
  },
  {
    "path": "daggr/CHANGELOG.md",
    "content": "# daggr\n\n## 0.8.0\n\n### Features\n\n- [#79](https://github.com/gradio-app/daggr/pull/79) [`d32cec2`](https://github.com/gradio-app/daggr/commit/d32cec21e93532ce7a7e66815922985c0bd5cb75) - feat: Introduce `InputNode` for grouping UI controls.  Thanks @elismasilva!\n- [#81](https://github.com/gradio-app/daggr/pull/81) [`34267c9`](https://github.com/gradio-app/daggr/commit/34267c9f93729cc4fa4839b15feb44a239897ec9) - stylized thin scrollbars.  Thanks @elismasilva!\n- [#77](https://github.com/gradio-app/daggr/pull/77) [`9002cca`](https://github.com/gradio-app/daggr/commit/9002cca2b312731572d4d80d7efe3cd9483e19b3) - Feature: Toggle Dark/Light Mode (based on @elismasilva's changes).  Thanks @abidlabs!\n\n## 0.7.0\n\n### Features\n\n- [#69](https://github.com/gradio-app/daggr/pull/69) [`297d104`](https://github.com/gradio-app/daggr/commit/297d104d0e3fbd6b59dfcd1f69c9a478de81bc3d) - Add stop button to cancel running nodes.  Thanks @abidlabs!\n- [#64](https://github.com/gradio-app/daggr/pull/64) [`a9e53c6`](https://github.com/gradio-app/daggr/commit/a9e53c64db6b3beede0b19b3876a3e50ab572233) - Fix ChoiceNode: public .name property and respect explicit names.  Thanks @abidlabs!\n- [#62](https://github.com/gradio-app/daggr/pull/62) [`695411c`](https://github.com/gradio-app/daggr/commit/695411ce94bc8fedd3320ac941ab41233bf8f887) - Standardize file handling: all files are path strings.  Thanks @abidlabs!\n- [#66](https://github.com/gradio-app/daggr/pull/66) [`1ed16f8`](https://github.com/gradio-app/daggr/commit/1ed16f806d535413c3718fc47d81d79d93d73ee0) - Fix gr.JSON rendering (use @render snippet syntax).  Thanks @abidlabs!\n- [#63](https://github.com/gradio-app/daggr/pull/63) [`00b05ac`](https://github.com/gradio-app/daggr/commit/00b05ac526642c91560cbebb258626a4511082c2) - Fix file downloads from private HF Spaces.  Thanks @abidlabs!\n- [#70](https://github.com/gradio-app/daggr/pull/70) [`33ccb74`](https://github.com/gradio-app/daggr/commit/33ccb7470a482ee1b09fddf1d51de81b1e2c4a40) - Fix gr.Image not rendering with initial value or None input.  Thanks @abidlabs!\n- [#68](https://github.com/gradio-app/daggr/pull/68) [`4b76dca`](https://github.com/gradio-app/daggr/commit/4b76dca815e2802b70ac03bb95fb03f21d81a8fa) - Add dependency hash tracking for upstream Spaces and models.  Thanks @abidlabs!\n\n## 0.6.0\n\n### Features\n\n- [#54](https://github.com/gradio-app/daggr/pull/54) [`c1abb26`](https://github.com/gradio-app/daggr/commit/c1abb260b254af6ca2060292232049ea89f0f944) - Fix cache.  Thanks @abidlabs!\n- [#56](https://github.com/gradio-app/daggr/pull/56) [`6e3dfc0`](https://github.com/gradio-app/daggr/commit/6e3dfc0a585b673adb77bb11ab1dcfd80d01da5a) - Add paste from clipboard button to Image component.  Thanks @abidlabs!\n- [#57](https://github.com/gradio-app/daggr/pull/57) [`76855ba`](https://github.com/gradio-app/daggr/commit/76855ba967e3f3132e8ec0590ae037d3151af310) - Fix dropdown options being clipped inside node.  Thanks @abidlabs!\n- [#58](https://github.com/gradio-app/daggr/pull/58) [`eb52b72`](https://github.com/gradio-app/daggr/commit/eb52b725b17d277e85f6eac1cc9d07f8068b011b) - Add theme support to daggr.  Thanks @abidlabs!\n- [#59](https://github.com/gradio-app/daggr/pull/59) [`78189a4`](https://github.com/gradio-app/daggr/commit/78189a4163b4041c814e52110b65754dc4dbf863) - Add run mode dropdown to control node execution scope.  Thanks @abidlabs!\n- [#39](https://github.com/gradio-app/daggr/pull/39) [`e8792ad`](https://github.com/gradio-app/daggr/commit/e8792ad1b5818ff8d13660b0b156f329bbc1c33a) - feat: add --state-db-path CLI arg and DAGGR_DB_PATH env var support.  Thanks @leith-bartrich!\n\n## 0.5.4\n\n### Features\n\n- [#27](https://github.com/gradio-app/daggr/pull/27) [`3952b2c`](https://github.com/gradio-app/daggr/commit/3952b2ccf30e7d18994f23049c2a2e84b323cfd6) - changes.  Thanks @abidlabs!\n\n## 0.5.3\n\n### Features\n\n- [#19](https://github.com/gradio-app/daggr/pull/19) [`cd956fe`](https://github.com/gradio-app/daggr/commit/cd956fe29945bdfd31bbe76fcb80d3f9c97cc301) - Add daggr tag to deployed Spaces.  Thanks @gary149!\n\n## 0.5.2\n\n### Features\n\n- [#14](https://github.com/gradio-app/daggr/pull/14) [`3fa412d`](https://github.com/gradio-app/daggr/commit/3fa412d678988608d49d46d99d193a05469892d2) - Fixes.  Thanks @abidlabs!\n\n## 0.5.1\n\n### Features\n\n- [#11](https://github.com/gradio-app/daggr/pull/11) [`ce1d5f4`](https://github.com/gradio-app/daggr/commit/ce1d5f4deaac60d95d9a021b0aa057bc2941b018) - Fixes.  Thanks @abidlabs!\n- [#13](https://github.com/gradio-app/daggr/pull/13) [`3246921`](https://github.com/gradio-app/daggr/commit/32469213dad5fd29a7ac85938dffbd976e2c6643) - fixes.  Thanks @abidlabs!\n\n## 0.5.0\n\n### Features\n\n- [#8](https://github.com/gradio-app/daggr/pull/8) [`e480065`](https://github.com/gradio-app/daggr/commit/e480065dd058dbf19053a80956dbfc90cf3e3caf) - Improving security around executor and various bug fixes.  Thanks @abidlabs!\n\n## 0.4.0\n\n### Features\n\n- [#1](https://github.com/gradio-app/daggr/pull/1) [`23538c8`](https://github.com/gradio-app/daggr/commit/23538c884fb3f2d84bbe4bf14f475dc85fa17c79) - Refactor files, add Dialogue component, and implement fully working podcast example.  Thanks @abidlabs!\n\n## 0.1.0\n\nInitial release"
  },
  {
    "path": "daggr/__init__.py",
    "content": "\"\"\"daggr - Build visual, node-based AI pipelines with Gradio Spaces.\n\ndaggr lets you create DAG (directed acyclic graph) pipelines that connect\nGradio Spaces, Hugging Face models, and Python functions into interactive\napplications.\n\nExample:\n    >>> from daggr import Graph, GradioNode, FnNode\n    >>> import gradio as gr\n    >>>\n    >>> tts = GradioNode(\n    ...     \"mrfakename/MeloTTS\",\n    ...     inputs={\"text\": gr.Textbox()},\n    ...     outputs={\"audio\": gr.Audio()},\n    ... )\n    >>> graph = Graph(\"TTS Demo\", nodes=[tts])\n    >>> graph.launch()\n\"\"\"\n\nimport json\nfrom pathlib import Path\n\n__version__ = json.loads((Path(__file__).parent / \"package.json\").read_text())[\n    \"version\"\n]\n\nfrom daggr.edge import Edge\nfrom daggr.graph import Graph\nfrom daggr.node import (\n    ChoiceNode,\n    FnNode,\n    GradioNode,\n    InferenceNode,\n    InputNode,\n    InteractionNode,\n    Node,\n)\nfrom daggr.port import ItemList, Port\nfrom daggr.server import DaggrServer\n\n__all__ = [\n    \"__version__\",\n    \"ChoiceNode\",\n    \"Edge\",\n    \"Graph\",\n    \"Node\",\n    \"FnNode\",\n    \"GradioNode\",\n    \"InferenceNode\",\n    \"InteractionNode\",\n    \"InputNode\",\n    \"ItemList\",\n    \"Port\",\n    \"DaggrServer\",\n]\n"
  },
  {
    "path": "daggr/_client_cache.py",
    "content": "from __future__ import annotations\n\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom daggr.state import get_daggr_cache_dir\n\n_client_cache: dict[str, Any] = {}\n_api_memory_cache: dict[str, dict] = {}\n_validated_set: set[str] = set()\n_model_task_cache: dict[str, str] = {}\n_dependency_hash_cache: dict[str, str] = {}\n_dependency_hash_loaded: bool = False\n\n\ndef _is_hot_reload() -> bool:\n    return os.environ.get(\"DAGGR_HOT_RELOAD\") == \"1\"\n\n\ndef _get_cache_path(src: str) -> Path:\n    src_hash = hashlib.md5(src.encode()).hexdigest()[:16]\n    return get_daggr_cache_dir() / f\"{src_hash}.json\"\n\n\ndef _get_validated_file() -> Path:\n    return get_daggr_cache_dir() / \"_validated.json\"\n\n\ndef _load_validated_set() -> None:\n    global _validated_set\n    if _validated_set:\n        return\n    if not _is_hot_reload():\n        return\n    validated_file = _get_validated_file()\n    if validated_file.exists():\n        try:\n            _validated_set = set(json.loads(validated_file.read_text()))\n        except (json.JSONDecodeError, OSError):\n            _validated_set = set()\n\n\ndef _save_validated_set() -> None:\n    if not _is_hot_reload():\n        return\n    try:\n        get_daggr_cache_dir().mkdir(parents=True, exist_ok=True)\n        _get_validated_file().write_text(json.dumps(list(_validated_set)))\n    except OSError:\n        pass\n\n\ndef is_validated(cache_key: tuple) -> bool:\n    if not _is_hot_reload():\n        return False\n    _load_validated_set()\n    return str(cache_key) in _validated_set\n\n\ndef mark_validated(cache_key: tuple) -> None:\n    if not _is_hot_reload():\n        return\n    _load_validated_set()\n    _validated_set.add(str(cache_key))\n    _save_validated_set()\n\n\ndef get_api_info(src: str) -> dict | None:\n    if src in _api_memory_cache:\n        return _api_memory_cache[src]\n\n    if not _is_hot_reload():\n        return None\n\n    cache_path = _get_cache_path(src)\n    if cache_path.exists():\n        try:\n            data = json.loads(cache_path.read_text())\n            _api_memory_cache[src] = data\n            return data\n        except (json.JSONDecodeError, OSError):\n            pass\n    return None\n\n\ndef set_api_info(src: str, info: dict) -> None:\n    _api_memory_cache[src] = info\n    if not _is_hot_reload():\n        return\n    try:\n        get_daggr_cache_dir().mkdir(parents=True, exist_ok=True)\n        cache_path = _get_cache_path(src)\n        cache_path.write_text(json.dumps(info))\n    except OSError:\n        pass\n\n\ndef get_client(src: str):\n    return _client_cache.get(src)\n\n\ndef set_client(src: str, client) -> None:\n    _client_cache[src] = client\n\n\ndef _get_model_task_cache_path() -> Path:\n    return get_daggr_cache_dir() / \"_model_tasks.json\"\n\n\ndef _load_model_task_cache() -> None:\n    global _model_task_cache\n    if _model_task_cache:\n        return\n    if not _is_hot_reload():\n        return\n    cache_path = _get_model_task_cache_path()\n    if cache_path.exists():\n        try:\n            _model_task_cache = json.loads(cache_path.read_text())\n        except (json.JSONDecodeError, OSError):\n            _model_task_cache = {}\n\n\ndef _save_model_task_cache() -> None:\n    if not _is_hot_reload():\n        return\n    try:\n        get_daggr_cache_dir().mkdir(parents=True, exist_ok=True)\n        _get_model_task_cache_path().write_text(json.dumps(_model_task_cache))\n    except OSError:\n        pass\n\n\ndef get_model_task(model: str) -> tuple[bool, str | None]:\n    \"\"\"Get cached task for a model.\n\n    Returns:\n        (found_in_cache, task) where:\n        - found_in_cache is True if we have cached info for this model\n        - task is the pipeline_tag (can be None if model has no task, or \"__NOT_FOUND__\" if model doesn't exist)\n    \"\"\"\n    if model in _model_task_cache:\n        return True, _model_task_cache[model]\n\n    if not _is_hot_reload():\n        return False, None\n\n    _load_model_task_cache()\n    if model in _model_task_cache:\n        return True, _model_task_cache[model]\n    return False, None\n\n\ndef set_model_task(model: str, task: str | None) -> None:\n    _model_task_cache[model] = task\n    _save_model_task_cache()\n\n\ndef set_model_not_found(model: str) -> None:\n    _model_task_cache[model] = \"__NOT_FOUND__\"\n    _save_model_task_cache()\n\n\ndef _get_dependency_hash_path() -> Path:\n    return get_daggr_cache_dir() / \"_dependency_hashes.json\"\n\n\ndef _load_dependency_hash_cache() -> None:\n    global _dependency_hash_cache, _dependency_hash_loaded\n    if _dependency_hash_loaded:\n        return\n    cache_path = _get_dependency_hash_path()\n    if cache_path.exists():\n        try:\n            _dependency_hash_cache = json.loads(cache_path.read_text())\n        except (json.JSONDecodeError, OSError):\n            _dependency_hash_cache = {}\n    _dependency_hash_loaded = True\n\n\ndef _save_dependency_hash_cache() -> None:\n    try:\n        get_daggr_cache_dir().mkdir(parents=True, exist_ok=True)\n        _get_dependency_hash_path().write_text(json.dumps(_dependency_hash_cache))\n    except OSError:\n        pass\n\n\ndef get_dependency_hash(src: str) -> str | None:\n    _load_dependency_hash_cache()\n    return _dependency_hash_cache.get(src)\n\n\ndef set_dependency_hash(src: str, sha: str) -> None:\n    _load_dependency_hash_cache()\n    _dependency_hash_cache[src] = sha\n    _save_dependency_hash_cache()\n"
  },
  {
    "path": "daggr/_utils.py",
    "content": "\"\"\"Internal utilities for daggr.\"\"\"\n\nfrom __future__ import annotations\n\nimport difflib\n\n\ndef suggest_similar(invalid: str, valid_options: set[str]) -> str | None:\n    \"\"\"Find a similar string from valid_options using fuzzy matching.\n\n    Args:\n        invalid: The invalid string to find matches for.\n        valid_options: Set of valid options to search through.\n\n    Returns:\n        The closest matching string if found with >= 60% similarity, else None.\n    \"\"\"\n    matches = difflib.get_close_matches(invalid, valid_options, n=1, cutoff=0.6)\n    return matches[0] if matches else None\n"
  },
  {
    "path": "daggr/cli.py",
    "content": "from __future__ import annotations\n\nimport argparse\nimport ast\nimport importlib.util\nimport os\nimport re\nimport shutil\nimport socket\nimport sqlite3\nimport sys\nimport tempfile\nimport threading\nimport time\nimport webbrowser\nfrom pathlib import Path\n\nINITIAL_PORT_VALUE = int(os.getenv(\"DAGGR_SERVER_PORT\", \"7860\"))\nTRY_NUM_PORTS = int(os.getenv(\"DAGGR_NUM_PORTS\", \"100\"))\n\n\ndef _find_available_port(host: str, start_port: int) -> int:\n    \"\"\"Find an available port starting from start_port.\"\"\"\n    for port in range(start_port, start_port + TRY_NUM_PORTS):\n        try:\n            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            s.bind((host if host != \"0.0.0.0\" else \"127.0.0.1\", port))\n            s.close()\n            return port\n        except OSError:\n            continue\n    raise OSError(\n        f\"Cannot find empty port in range: {start_port}-{start_port + TRY_NUM_PORTS - 1}. \"\n        f\"You can specify a different port by setting the DAGGR_SERVER_PORT environment variable \"\n        f\"or passing the --port parameter.\"\n    )\n\n\ndef find_python_imports(file_path: Path) -> list[Path]:\n    \"\"\"Find local Python files imported by the given file.\"\"\"\n    imports = []\n    try:\n        with open(file_path) as f:\n            content = f.read()\n\n        tree = ast.parse(content)\n\n        file_dir = file_path.parent\n\n        for node in ast.walk(tree):\n            if isinstance(node, ast.Import):\n                for alias in node.names:\n                    module_path = file_dir / f\"{alias.name.replace('.', '/')}.py\"\n                    if module_path.exists():\n                        imports.append(module_path)\n            elif isinstance(node, ast.ImportFrom):\n                if node.module:\n                    module_path = file_dir / f\"{node.module.replace('.', '/')}.py\"\n                    if module_path.exists():\n                        imports.append(module_path)\n                    package_init = (\n                        file_dir / node.module.replace(\".\", \"/\") / \"__init__.py\"\n                    )\n                    if package_init.exists():\n                        imports.append(package_init.parent)\n    except Exception:\n        pass\n    return imports\n\n\ndef main():\n    if len(sys.argv) > 1 and sys.argv[1] == \"deploy\":\n        _deploy_main()\n        return\n\n    parser = argparse.ArgumentParser(\n        prog=\"daggr\",\n        description=\"Run a daggr app with hot reload\",\n    )\n    parser.add_argument(\n        \"script\",\n        help=\"Path to the Python script containing the daggr Graph\",\n    )\n    parser.add_argument(\n        \"--host\",\n        default=\"127.0.0.1\",\n        help=\"Host to bind to (default: 127.0.0.1)\",\n    )\n    parser.add_argument(\n        \"--port\",\n        type=int,\n        default=7860,\n        help=\"Port to bind to (default: 7860)\",\n    )\n    parser.add_argument(\n        \"--no-reload\",\n        action=\"store_true\",\n        help=\"Disable auto-reload\",\n    )\n    parser.add_argument(\n        \"--watch-daggr\",\n        action=\"store_true\",\n        default=True,\n        help=\"Watch daggr source for changes (default: True, useful for development)\",\n    )\n    parser.add_argument(\n        \"--no-watch-daggr\",\n        action=\"store_true\",\n        help=\"Don't watch daggr source for changes\",\n    )\n    parser.add_argument(\n        \"--delete-sheets\",\n        action=\"store_true\",\n        help=\"Delete all cached data (sheets, results, downloaded files) for this project and exit\",\n    )\n    parser.add_argument(\n        \"--force\",\n        \"-f\",\n        action=\"store_true\",\n        help=\"Skip confirmation prompts (use with --delete-sheets)\",\n    )\n    parser.add_argument(\n        \"--state-db-path\",\n        help=\"Optional path to SQLite state database. Overrides DAGGR_DB_PATH env var. Defaults to HuggingFace cache.\",\n    )\n\n    args = parser.parse_args()\n\n    script_path = Path(args.script).resolve()\n    if not script_path.exists():\n        print(f\"Error: Script not found: {script_path}\")\n        sys.exit(1)\n\n    if not script_path.suffix == \".py\":\n        print(f\"Error: Script must be a Python file: {script_path}\")\n        sys.exit(1)\n\n    if args.delete_sheets:\n        _delete_sheets(script_path, force=args.force)\n        sys.exit(0)\n\n    watch_daggr = args.watch_daggr and not args.no_watch_daggr\n\n    os.environ[\"DAGGR_SCRIPT_PATH\"] = str(script_path)\n    os.environ[\"DAGGR_HOST\"] = args.host\n    os.environ[\"DAGGR_PORT\"] = str(args.port)\n    if args.state_db_path:\n        os.environ[\"DAGGR_DB_PATH\"] = str(Path(args.state_db_path).resolve())\n\n    if args.no_reload:\n        _run_script(script_path, args.host, args.port)\n    else:\n        os.environ[\"DAGGR_HOT_RELOAD\"] = \"1\"\n        _run_with_reload(script_path, args.host, args.port, watch_daggr)\n\n\ndef _deploy_main():\n    \"\"\"Entry point for the deploy subcommand.\"\"\"\n    parser = argparse.ArgumentParser(\n        prog=\"daggr deploy\",\n        description=\"Deploy a daggr app to Hugging Face Spaces\",\n    )\n    parser.add_argument(\n        \"script\",\n        help=\"Path to the Python script containing the daggr Graph\",\n    )\n    parser.add_argument(\n        \"--name\",\n        \"-n\",\n        help=\"Space name (default: derived from Graph name)\",\n    )\n    parser.add_argument(\n        \"--title\",\n        \"-t\",\n        help=\"Display title for the Space (default: Graph name)\",\n    )\n    parser.add_argument(\n        \"--org\",\n        \"-o\",\n        help=\"Organization or username to deploy under (default: your HF account)\",\n    )\n    parser.add_argument(\n        \"--private\",\n        \"-p\",\n        action=\"store_true\",\n        help=\"Make the Space private\",\n    )\n    parser.add_argument(\n        \"--hardware\",\n        default=\"cpu-basic\",\n        help=\"Hardware tier (default: cpu-basic). Options: cpu-basic, cpu-upgrade, t4-small, t4-medium, a10g-small, etc.\",\n    )\n    parser.add_argument(\n        \"--secret\",\n        \"-s\",\n        action=\"append\",\n        dest=\"secrets\",\n        metavar=\"KEY=VALUE\",\n        help=\"Add a secret (can be repeated). Example: --secret HF_TOKEN=xxx\",\n    )\n    parser.add_argument(\n        \"--requirements\",\n        \"-r\",\n        help=\"Path to requirements.txt (default: auto-detect or generate)\",\n    )\n    parser.add_argument(\n        \"--dry-run\",\n        action=\"store_true\",\n        help=\"Preview what would be deployed without actually deploying\",\n    )\n\n    args = parser.parse_args(sys.argv[2:])\n\n    script_path = Path(args.script).resolve()\n    if not script_path.exists():\n        print(f\"Error: Script not found: {script_path}\")\n        sys.exit(1)\n\n    if not script_path.suffix == \".py\":\n        print(f\"Error: Script must be a Python file: {script_path}\")\n        sys.exit(1)\n\n    secrets = {}\n    if args.secrets:\n        for secret in args.secrets:\n            if \"=\" not in secret:\n                print(f\"Error: Invalid secret format '{secret}'. Use KEY=VALUE\")\n                sys.exit(1)\n            key, value = secret.split(\"=\", 1)\n            secrets[key] = value\n\n    _deploy(\n        script_path=script_path,\n        name=args.name,\n        title=args.title,\n        org=args.org,\n        private=args.private,\n        hardware=args.hardware,\n        secrets=secrets,\n        requirements_path=args.requirements,\n        dry_run=args.dry_run,\n    )\n\n\ndef _extract_graph(script_path: Path):\n    \"\"\"Extract the Graph object from a script without running it.\"\"\"\n    from daggr.graph import Graph\n\n    sys.path.insert(0, str(script_path.parent))\n\n    original_launch = Graph.launch\n    captured_graph = None\n\n    def capture_launch(self, **kwargs):\n        nonlocal captured_graph\n        captured_graph = self\n\n    Graph.launch = capture_launch\n\n    try:\n        spec = importlib.util.spec_from_file_location(\"__daggr_deploy__\", script_path)\n        if spec is None or spec.loader is None:\n            print(f\"Error: Could not load script: {script_path}\")\n            sys.exit(1)\n\n        module = importlib.util.module_from_spec(spec)\n        sys.modules[\"__daggr_deploy__\"] = module\n        spec.loader.exec_module(module)\n    finally:\n        Graph.launch = original_launch\n\n    if captured_graph is None:\n        for name in dir(module):\n            obj = getattr(module, name)\n            if isinstance(obj, Graph):\n                captured_graph = obj\n                break\n\n    if captured_graph is None:\n        print(f\"Error: No Graph found in {script_path}\")\n        sys.exit(1)\n\n    return captured_graph\n\n\ndef _sanitize_space_name(name: str) -> str:\n    \"\"\"Convert a Graph name to a valid HF Space name.\"\"\"\n    sanitized = re.sub(r\"[^a-zA-Z0-9\\s-]\", \"\", name)\n    sanitized = re.sub(r\"[\\s_]+\", \"-\", sanitized)\n    sanitized = sanitized.lower().strip(\"-\")\n    return sanitized or \"daggr-app\"\n\n\ndef _deploy(\n    script_path: Path,\n    name: str | None,\n    title: str | None,\n    org: str | None,\n    private: bool,\n    hardware: str,\n    secrets: dict[str, str],\n    requirements_path: str | None,\n    dry_run: bool,\n):\n    \"\"\"Deploy a daggr app to Hugging Face Spaces.\"\"\"\n    import huggingface_hub\n    from huggingface_hub import HfApi\n\n    import daggr\n\n    print(\"\\n  Extracting Graph from script...\")\n    graph = _extract_graph(script_path)\n\n    space_name = name or _sanitize_space_name(graph.name)\n    space_title = title or graph.name\n\n    print(f\"  Graph name: {graph.name}\")\n    print(f\"  Space name: {space_name}\")\n    print(f\"  Space title: {space_title}\")\n\n    hf_api = HfApi()\n    whoami = None\n    login_needed = False\n\n    try:\n        whoami = hf_api.whoami()\n        if whoami[\"auth\"][\"accessToken\"][\"role\"] != \"write\":\n            login_needed = True\n    except Exception:\n        login_needed = True\n\n    if login_needed:\n        print(\"\\n  Need 'write' access token to create a Spaces repo.\")\n        huggingface_hub.login(add_to_git_credential=False)\n        whoami = hf_api.whoami()\n\n    username = whoami[\"name\"]\n    namespace = org or username\n    repo_id = f\"{namespace}/{space_name}\"\n\n    print(f\"\\n  Target: https://huggingface.co/spaces/{repo_id}\")\n    print(f\"  Hardware: {hardware}\")\n    print(f\"  Private: {private}\")\n    if secrets:\n        print(f\"  Secrets: {list(secrets.keys())}\")\n\n    local_imports = find_python_imports(script_path)\n    print(\"\\n  Files to upload:\")\n    print(f\"    • app.py (from {script_path.name})\")\n    print(\"    • requirements.txt\")\n    print(\"    • README.md\")\n    for imp in local_imports:\n        if imp.is_file():\n            print(f\"    • {imp.name}\")\n        else:\n            print(f\"    • {imp.name}/ (package)\")\n\n    if dry_run:\n        print(\"\\n  [Dry run] No changes made.\")\n        return\n\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmpdir = Path(tmpdir)\n\n        shutil.copy(script_path, tmpdir / \"app.py\")\n\n        for imp in local_imports:\n            if imp.is_file():\n                shutil.copy(imp, tmpdir / imp.name)\n            else:\n                shutil.copytree(imp, tmpdir / imp.name)\n\n        if requirements_path:\n            req_path = Path(requirements_path)\n            if not req_path.exists():\n                print(f\"Error: Requirements file not found: {req_path}\")\n                sys.exit(1)\n            shutil.copy(req_path, tmpdir / \"requirements.txt\")\n\n            with open(tmpdir / \"requirements.txt\", \"r\") as f:\n                req_content = f.read()\n            if \"daggr\" not in req_content:\n                with open(tmpdir / \"requirements.txt\", \"a\") as f:\n                    f.write(f\"\\ndaggr>={daggr.__version__}\\n\")\n        else:\n            script_dir = script_path.parent\n            existing_req = script_dir / \"requirements.txt\"\n            if existing_req.exists():\n                shutil.copy(existing_req, tmpdir / \"requirements.txt\")\n                with open(tmpdir / \"requirements.txt\", \"r\") as f:\n                    req_content = f.read()\n                if \"daggr\" not in req_content:\n                    with open(tmpdir / \"requirements.txt\", \"a\") as f:\n                        f.write(f\"\\ndaggr>={daggr.__version__}\\n\")\n            else:\n                with open(tmpdir / \"requirements.txt\", \"w\") as f:\n                    f.write(f\"daggr>={daggr.__version__}\\n\")\n\n        readme_content = f\"\"\"---\ntitle: {space_title}\nemoji: 🔀\ncolorFrom: blue\ncolorTo: purple\nsdk: gradio\nsdk_version: \"{_get_gradio_version()}\"\napp_file: app.py\npinned: false\ntags:\n  - daggr\n---\n\n# {space_title}\n\nThis Space was deployed using [daggr](https://github.com/gradio-app/daggr).\n\"\"\"\n        with open(tmpdir / \"README.md\", \"w\") as f:\n            f.write(readme_content)\n\n        print(\"\\n  Creating Space repository...\")\n        try:\n            hf_api.create_repo(\n                repo_id=repo_id,\n                repo_type=\"space\",\n                space_sdk=\"gradio\",\n                space_hardware=hardware,\n                private=private,\n                exist_ok=True,\n            )\n        except Exception as e:\n            print(f\"Error creating repository: {e}\")\n            sys.exit(1)\n\n        print(\"  Uploading files...\")\n        try:\n            hf_api.upload_folder(\n                repo_id=repo_id,\n                repo_type=\"space\",\n                folder_path=str(tmpdir),\n            )\n        except Exception as e:\n            print(f\"Error uploading files: {e}\")\n            sys.exit(1)\n\n        if secrets:\n            print(\"  Adding secrets...\")\n            for secret_name, secret_value in secrets.items():\n                try:\n                    hf_api.add_space_secret(repo_id, secret_name, secret_value)\n                except Exception as e:\n                    print(f\"  Warning: Could not add secret '{secret_name}': {e}\")\n\n    print(f\"\\n  ✓ Deployed to https://huggingface.co/spaces/{repo_id}\")\n    print(\"    The Space may take a few minutes to build and start.\\n\")\n\n\ndef _get_gradio_version() -> str:\n    \"\"\"Get the installed Gradio version.\"\"\"\n    try:\n        import gradio\n\n        return gradio.__version__\n    except ImportError:\n        return \"5.0.0\"\n\n\ndef _delete_sheets(script_path: Path, force: bool = False):\n    \"\"\"Delete all cached data for the project defined in the script.\"\"\"\n    from daggr.graph import Graph\n    from daggr.state import get_daggr_cache_dir\n\n    sys.path.insert(0, str(script_path.parent))\n\n    original_launch = Graph.launch\n    captured_graph = None\n\n    def capture_launch(self, **kwargs):\n        nonlocal captured_graph\n        captured_graph = self\n\n    Graph.launch = capture_launch\n\n    try:\n        spec = importlib.util.spec_from_file_location(\"__daggr_reset__\", script_path)\n        if spec is None or spec.loader is None:\n            print(f\"Error: Could not load script: {script_path}\")\n            sys.exit(1)\n\n        module = importlib.util.module_from_spec(spec)\n        sys.modules[\"__daggr_reset__\"] = module\n        spec.loader.exec_module(module)\n    finally:\n        Graph.launch = original_launch\n\n    if captured_graph is None:\n        for name in dir(module):\n            obj = getattr(module, name)\n            if isinstance(obj, Graph):\n                captured_graph = obj\n                break\n\n    if captured_graph is None:\n        print(f\"Error: No Graph found in {script_path}\")\n        sys.exit(1)\n\n    persist_key = captured_graph.persist_key\n    if not persist_key:\n        print(\"Error: Graph has no persist_key (persistence is disabled)\")\n        sys.exit(1)\n\n    cache_dir = get_daggr_cache_dir()\n    db_path = cache_dir / \"sessions.db\"\n\n    if not db_path.exists():\n        print(f\"No cache found for project '{persist_key}'\")\n        return\n\n    conn = sqlite3.connect(str(db_path))\n    cursor = conn.cursor()\n\n    cursor.execute(\n        \"SELECT sheet_id FROM sheets WHERE graph_name = ?\",\n        (persist_key,),\n    )\n    sheet_ids = [row[0] for row in cursor.fetchall()]\n\n    if not sheet_ids:\n        print(f\"No cached data found for project '{persist_key}'\")\n        conn.close()\n        return\n\n    print(f\"\\nProject: {persist_key}\")\n    print(f\"This will delete {len(sheet_ids)} sheet(s) and all associated data.\")\n    print(f\"Cache location: {cache_dir}\\n\")\n\n    if not force:\n        try:\n            response = (\n                input(\"Are you sure you want to continue? [y/N] \").strip().lower()\n            )\n        except (EOFError, KeyboardInterrupt):\n            print(\"\\nAborted.\")\n            conn.close()\n            return\n\n        if response not in (\"y\", \"yes\"):\n            print(\"Aborted.\")\n            conn.close()\n            return\n\n    for sheet_id in sheet_ids:\n        cursor.execute(\"DELETE FROM node_inputs WHERE sheet_id = ?\", (sheet_id,))\n        cursor.execute(\"DELETE FROM node_results WHERE sheet_id = ?\", (sheet_id,))\n        cursor.execute(\"DELETE FROM sheets WHERE sheet_id = ?\", (sheet_id,))\n\n    conn.commit()\n    conn.close()\n\n    print(f\"\\n✓ Deleted {len(sheet_ids)} sheet(s) for project '{persist_key}'\")\n\n\ndef _run_script(script_path: Path, host: str, port: int):\n    \"\"\"Run the script directly without reload.\"\"\"\n    spec = importlib.util.spec_from_file_location(\"__daggr_main__\", script_path)\n    if spec is None or spec.loader is None:\n        print(f\"Error: Could not load script: {script_path}\")\n        sys.exit(1)\n\n    sys.path.insert(0, str(script_path.parent))\n\n    module = importlib.util.module_from_spec(spec)\n    sys.modules[\"__daggr_main__\"] = module\n    spec.loader.exec_module(module)\n\n\ndef _run_with_reload(script_path: Path, host: str, port: int, watch_daggr: bool):\n    \"\"\"Run the script with uvicorn hot reload.\"\"\"\n    import uvicorn\n\n    actual_port = _find_available_port(host, port)\n    if actual_port != port:\n        print(f\"\\n  Port {port} is in use, using {actual_port} instead.\")\n\n    reload_dirs = [str(script_path.parent)]\n\n    local_imports = find_python_imports(script_path)\n    for imp in local_imports:\n        imp_dir = str(imp if imp.is_dir() else imp.parent)\n        if imp_dir not in reload_dirs:\n            reload_dirs.append(imp_dir)\n\n    if watch_daggr:\n        daggr_dir = Path(__file__).parent\n        daggr_src = str(daggr_dir)\n        if daggr_src not in reload_dirs:\n            reload_dirs.append(daggr_src)\n\n    reload_includes = [\"*.py\"]\n\n    print(\"\\n  daggr dev server starting...\")\n    print(\"  Watching for changes in:\")\n    for d in reload_dirs:\n        print(f\"    • {d}\")\n    print()\n\n    os.environ[\"DAGGR_PORT\"] = str(actual_port)\n\n    def open_browser():\n        time.sleep(1.0)\n        webbrowser.open_new_tab(f\"http://{host}:{actual_port}\")\n\n    threading.Thread(target=open_browser, daemon=True).start()\n\n    uvicorn.run(\n        \"daggr.cli:_create_app\",\n        factory=True,\n        host=host,\n        port=actual_port,\n        reload=True,\n        reload_dirs=reload_dirs,\n        reload_includes=reload_includes,\n        log_level=\"warning\",\n    )\n\n\ndef _create_app():\n    \"\"\"Factory function for uvicorn to create the FastAPI app.\"\"\"\n    from daggr.graph import Graph\n    from daggr.server import DaggrServer\n\n    script_path = Path(os.environ[\"DAGGR_SCRIPT_PATH\"])\n\n    if str(script_path.parent) not in sys.path:\n        sys.path.insert(0, str(script_path.parent))\n\n    modules_to_remove = [m for m in sys.modules if m.startswith(\"__daggr_user_script_\")]\n    for m in modules_to_remove:\n        del sys.modules[m]\n\n    module_name = f\"__daggr_user_script_{id(script_path)}__\"\n\n    spec = importlib.util.spec_from_file_location(module_name, script_path)\n    if spec is None or spec.loader is None:\n        raise RuntimeError(f\"Could not load script: {script_path}\")\n\n    original_launch = Graph.launch\n    captured_graph = None\n    launch_kwargs = {}\n\n    def capture_launch(self, **kwargs):\n        nonlocal captured_graph, launch_kwargs\n        captured_graph = self\n        launch_kwargs = kwargs\n\n    Graph.launch = capture_launch\n\n    try:\n        module = importlib.util.module_from_spec(spec)\n        sys.modules[module_name] = module\n        spec.loader.exec_module(module)\n    finally:\n        Graph.launch = original_launch\n\n    if captured_graph is None:\n        for name in dir(module):\n            obj = getattr(module, name)\n            if isinstance(obj, Graph):\n                captured_graph = obj\n                break\n\n    if captured_graph is None:\n        raise RuntimeError(\n            f\"No Graph found in {script_path}. \"\n            \"Make sure your script defines a Graph and calls graph.launch() \"\n            \"or has a Graph instance at module level.\"\n        )\n\n    captured_graph._validate_edges()\n    server = DaggrServer(captured_graph)\n\n    base_url = f\"http://{os.environ['DAGGR_HOST']}:{os.environ['DAGGR_PORT']}\"\n    print(f\"\\n  UI running at: {base_url}\")\n    print(f\"  API server at: {base_url}/api\\n\")\n\n    return server.app\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "daggr/edge.py",
    "content": "\"\"\"Edge module for connecting ports between nodes.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom daggr.port import GatheredPort, ScatteredPort\n\nif TYPE_CHECKING:\n    from daggr.port import PortLike\n\n\nclass Edge:\n    \"\"\"Represents a connection between two ports in a graph.\n\n    Edges connect an output port of one node to an input port of another,\n    defining how data flows through the graph.\n\n    Attributes:\n        source_node: The node providing the output.\n        source_port: Name of the output port.\n        target_node: The node receiving the input.\n        target_port: Name of the input port.\n        is_scattered: True if this edge scatters a list to multiple executions.\n        is_gathered: True if this edge gathers results back into a list.\n        item_key: For scattered edges, the key to extract from each item.\n    \"\"\"\n\n    def __init__(self, source: PortLike, target: PortLike):\n        self.is_scattered = isinstance(source, ScatteredPort)\n        self.is_gathered = isinstance(source, GatheredPort)\n        self.item_key: str | None = None\n\n        if self.is_scattered:\n            self.item_key = source.item_key\n\n        self.source_node = source.node\n        self.source_port = source.name\n        self.target_node = target.node\n        self.target_port = target.name\n\n    def __repr__(self):\n        prefix = \"\"\n        if self.is_scattered:\n            key_info = f\"['{self.item_key}']\" if self.item_key else \"\"\n            prefix = f\"scatter{key_info}:\"\n        elif self.is_gathered:\n            prefix = \"gather:\"\n        return (\n            f\"Edge({prefix}{self.source_node._name}.{self.source_port} -> \"\n            f\"{self.target_node._name}.{self.target_port})\"\n        )\n\n    def as_tuple(self) -> tuple[str, str, str, str]:\n        return (\n            self.source_node._name,\n            self.source_port,\n            self.target_node._name,\n            self.target_port,\n        )\n"
  },
  {
    "path": "daggr/executor.py",
    "content": "\"\"\"Executor for daggr graphs.\n\nThis module provides the AsyncExecutor for running graph nodes with proper\nconcurrency control and session isolation.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport base64\nimport hashlib\nimport uuid\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\nfrom urllib.parse import urlparse\n\nfrom gradio_client.utils import is_file_obj_with_meta, traverse\n\nfrom daggr.node import (\n    ChoiceNode,\n    FnNode,\n    GradioNode,\n    InferenceNode,\n    InputNode,\n    InteractionNode,\n)\nfrom daggr.session import ExecutionSession\nfrom daggr.state import get_daggr_files_dir\n\nif TYPE_CHECKING:\n    from daggr.graph import Graph\n\n\nclass FileValue(str):\n    \"\"\"A string subclass that marks a value as a file URL/path from Gradio output.\"\"\"\n\n    pass\n\n\ndef _download_file(url: str, hf_token: str | None = None) -> str:\n    import httpx\n\n    parsed = urlparse(url)\n    ext = Path(parsed.path).suffix or \".bin\"\n    url_hash = hashlib.md5(url.encode()).hexdigest()[:16]\n    filename = f\"{url_hash}{ext}\"\n\n    files_dir = get_daggr_files_dir()\n    local_path = files_dir / filename\n\n    if not local_path.exists():\n        headers = {}\n        if hf_token:\n            headers[\"Authorization\"] = f\"Bearer {hf_token}\"\n        with httpx.Client(follow_redirects=True) as client:\n            response = client.get(url, headers=headers)\n            response.raise_for_status()\n            local_path.write_bytes(response.content)\n\n    return str(local_path)\n\n\ndef _postprocess_inference_result(task: str | None, result: Any) -> Any:\n    \"\"\"Unwrap HF Inference Client result objects to get the actual data.\"\"\"\n    if result is None:\n        return None\n\n    if task == \"automatic-speech-recognition\":\n        return getattr(result, \"text\", result)\n    elif task == \"translation\":\n        return getattr(result, \"translation_text\", result)\n    elif task == \"summarization\":\n        return getattr(result, \"summary_text\", result)\n    elif task in (\n        \"audio-classification\",\n        \"image-classification\",\n        \"text-classification\",\n    ):\n        if isinstance(result, list) and result:\n            return {item.label: item.score for item in result if hasattr(item, \"label\")}\n        return result\n    elif task == \"image-to-text\":\n        return getattr(result, \"generated_text\", result)\n    elif task == \"question-answering\":\n        if hasattr(result, \"answer\"):\n            return result.answer\n        return result\n    elif task in (\"text-to-speech\", \"text-to-audio\"):\n        if isinstance(result, bytes):\n            file_path = get_daggr_files_dir() / f\"{uuid.uuid4()}.wav\"\n            file_path.write_bytes(result)\n            return str(file_path)\n        return result\n    elif task in (\"text-to-image\", \"image-to-image\"):\n        if isinstance(result, dict):\n            if \"images\" in result:\n                result = result[\"images\"][0] if result[\"images\"] else result\n            elif \"image\" in result:\n                result = result[\"image\"]\n        if hasattr(result, \"save\"):\n            file_path = get_daggr_files_dir() / f\"{uuid.uuid4()}.png\"\n            result.save(file_path)\n            return str(file_path)\n        return result\n\n    return result\n\n\ndef _call_inference_task(client: Any, task: str | None, inputs: dict[str, Any]) -> Any:\n    primary_input = None\n    if task in (\n        \"image-to-image\",\n        \"image-classification\",\n        \"image-to-text\",\n        \"object-detection\",\n        \"image-segmentation\",\n        \"visual-question-answering\",\n        \"document-question-answering\",\n    ):\n        primary_input = inputs.get(\"image\")\n    elif task in (\n        \"automatic-speech-recognition\",\n        \"audio-classification\",\n        \"audio-to-audio\",\n    ):\n        primary_input = inputs.get(\"audio\")\n\n    if primary_input is None:\n        primary_input = next(iter(inputs.values()), None) if inputs else None\n\n    if primary_input is None:\n        return None\n\n    task_method_map = {\n        \"text-generation\": \"text_generation\",\n        \"text2text-generation\": \"text_generation\",\n        \"text-to-image\": \"text_to_image\",\n        \"image-to-image\": \"image_to_image\",\n        \"image-to-text\": \"image_to_text\",\n        \"image-to-video\": \"image_to_video\",\n        \"text-to-video\": \"text_to_video\",\n        \"text-to-speech\": \"text_to_speech\",\n        \"text-to-audio\": \"text_to_audio\",\n        \"automatic-speech-recognition\": \"automatic_speech_recognition\",\n        \"audio-to-audio\": \"audio_to_audio\",\n        \"audio-classification\": \"audio_classification\",\n        \"image-classification\": \"image_classification\",\n        \"object-detection\": \"object_detection\",\n        \"image-segmentation\": \"image_segmentation\",\n        \"translation\": \"translation\",\n        \"summarization\": \"summarization\",\n        \"feature-extraction\": \"feature_extraction\",\n        \"fill-mask\": \"fill_mask\",\n        \"question-answering\": \"question_answering\",\n        \"table-question-answering\": \"table_question_answering\",\n        \"sentence-similarity\": \"sentence_similarity\",\n        \"zero-shot-classification\": \"zero_shot_classification\",\n        \"zero-shot-image-classification\": \"zero_shot_image_classification\",\n        \"document-question-answering\": \"document_question_answering\",\n        \"visual-question-answering\": \"visual_question_answering\",\n    }\n\n    method_name = (\n        task_method_map.get(task, \"text_generation\") if task else \"text_generation\"\n    )\n    method = getattr(client, method_name, None)\n\n    file_input_tasks = {\n        \"image-to-image\",\n        \"image-classification\",\n        \"image-to-text\",\n        \"object-detection\",\n        \"image-segmentation\",\n        \"visual-question-answering\",\n        \"document-question-answering\",\n        \"automatic-speech-recognition\",\n        \"audio-classification\",\n        \"audio-to-audio\",\n    }\n\n    if task in file_input_tasks and isinstance(primary_input, str):\n        primary_input = _read_file_as_bytes(primary_input)\n\n    try:\n        if method is None:\n            result = client.text_generation(primary_input)\n        elif task in (\"image-to-image\",):\n            prompt = inputs.get(\"prompt\", \"\")\n            result = method(primary_input, prompt=prompt)\n        elif task in (\"visual-question-answering\", \"document-question-answering\"):\n            question = inputs.get(\"question\", inputs.get(\"prompt\", \"\"))\n            result = method(primary_input, question=question)\n        else:\n            result = method(primary_input)\n    except KeyError as e:\n        raise RuntimeError(\n            f\"Provider returned unexpected response format for task '{task}'. \"\n            f\"Missing key: {e}. This model may require a specific provider \"\n            f\"(e.g., 'model_name:fal-ai' or 'model_name:replicate').\"\n        ) from e\n\n    return _postprocess_inference_result(task, result)\n\n\ndef _read_file_as_bytes(file_path: str) -> bytes:\n    \"\"\"Read a file path or data URL as bytes.\"\"\"\n    if file_path.startswith(\"data:\"):\n        try:\n            _, encoded = file_path.split(\",\", 1)\n            return base64.b64decode(encoded)\n        except Exception:\n            pass\n\n    path = Path(file_path)\n    if path.exists():\n        return path.read_bytes()\n\n    return file_path\n\n\nclass AsyncExecutor:\n    \"\"\"Async executor for graph nodes.\n\n    This executor is stateless - all state is held in the ExecutionSession.\n    It handles concurrency control:\n    - GradioNode/InferenceNode: run concurrently (external API calls)\n    - FnNode: sequential by default, configurable via concurrent/concurrency_group\n    \"\"\"\n\n    def __init__(self, graph: Graph):\n        self.graph = graph\n\n    def _get_client_for_gradio_node(\n        self, session: ExecutionSession, gradio_node, cache_key: str\n    ):\n        from daggr import _client_cache\n\n        token_cache_key = f\"{cache_key}__token_{hash(session.hf_token or '')}\"\n        if token_cache_key in session.clients:\n            return session.clients[token_cache_key]\n\n        if gradio_node._run_locally:\n            from daggr.local_space import get_local_client\n\n            client = get_local_client(gradio_node)\n            if client is not None:\n                session.clients[token_cache_key] = client\n                return client\n\n        if session.hf_token:\n            from gradio_client import Client\n\n            client = Client(\n                gradio_node._src,\n                download_files=False,\n                verbose=False,\n                token=session.hf_token,\n            )\n        else:\n            client = _client_cache.get_client(gradio_node._src)\n            if client is None:\n                from gradio_client import Client\n\n                client = Client(\n                    gradio_node._src,\n                    download_files=False,\n                    verbose=False,\n                )\n                _client_cache.set_client(gradio_node._src, client)\n\n        session.clients[token_cache_key] = client\n        return client\n\n    def _get_client(self, session: ExecutionSession, node_name: str):\n        node = self.graph.nodes[node_name]\n\n        if isinstance(node, ChoiceNode):\n            variant_idx = session.selected_variants.get(node_name, 0)\n            variant = node._variants[variant_idx]\n            if isinstance(variant, GradioNode):\n                cache_key = f\"{node_name}__variant_{variant_idx}\"\n                return self._get_client_for_gradio_node(session, variant, cache_key)\n            return None\n\n        if not isinstance(node, GradioNode):\n            return None\n\n        return self._get_client_for_gradio_node(session, node, node_name)\n\n    def _get_scattered_input_edges(self, node_name: str) -> list:\n        scattered = []\n        for edge in self.graph._edges:\n            if edge.target_node._name == node_name and edge.is_scattered:\n                scattered.append(edge)\n        return scattered\n\n    def _get_gathered_input_edges(self, node_name: str) -> list:\n        gathered = []\n        for edge in self.graph._edges:\n            if edge.target_node._name == node_name and edge.is_gathered:\n                gathered.append(edge)\n        return gathered\n\n    def _prepare_inputs(\n        self, session: ExecutionSession, node_name: str, skip_scattered: bool = False\n    ) -> dict[str, Any]:\n        inputs = {}\n\n        for edge in self.graph._edges:\n            if edge.target_node._name == node_name:\n                if skip_scattered and edge.is_scattered:\n                    continue\n\n                source_name = edge.source_node._name\n                source_output = edge.source_port\n                target_input = edge.target_port\n\n                if source_name in session.results:\n                    source_result = session.results[source_name]\n\n                    if (\n                        edge.is_gathered\n                        and isinstance(source_result, dict)\n                        and \"_scattered_results\" in source_result\n                    ):\n                        scattered_results = source_result[\"_scattered_results\"]\n                        extracted = []\n                        for item_result in scattered_results:\n                            if (\n                                isinstance(item_result, dict)\n                                and source_output in item_result\n                            ):\n                                extracted.append(item_result[source_output])\n                            else:\n                                extracted.append(item_result)\n                        inputs[target_input] = extracted\n                    elif (\n                        isinstance(source_result, dict)\n                        and source_output in source_result\n                    ):\n                        inputs[target_input] = source_result[source_output]\n                    elif isinstance(source_result, (list, tuple)):\n                        try:\n                            output_idx = int(\n                                source_output.replace(\"output_\", \"\").replace(\n                                    \"output\", \"0\"\n                                )\n                            )\n                            if 0 <= output_idx < len(source_result):\n                                inputs[target_input] = source_result[output_idx]\n                        except (ValueError, TypeError):\n                            if len(source_result) > 0:\n                                inputs[target_input] = source_result[0]\n                    else:\n                        inputs[target_input] = source_result\n\n        return inputs\n\n    def _execute_single_node_sync(\n        self, session: ExecutionSession, node_name: str, inputs: dict[str, Any]\n    ) -> Any:\n        \"\"\"Synchronous node execution (called from thread pool for FnNode).\"\"\"\n        node = self.graph.nodes[node_name]\n\n        if isinstance(node, ChoiceNode):\n            variant_idx = session.selected_variants.get(node_name, 0)\n            variant = node._variants[variant_idx]\n            return self._execute_variant_node_sync(session, node_name, variant, inputs)\n\n        all_inputs = {}\n        for port_name, value in node._fixed_inputs.items():\n            all_inputs[port_name] = value() if callable(value) else value\n        for port_name, component in node._input_components.items():\n            if hasattr(component, \"value\"):\n                val = component.value\n                if is_file_obj_with_meta(val):\n                    val = val[\"path\"]\n                all_inputs[port_name] = val\n        all_inputs.update(inputs)\n\n        if isinstance(node, GradioNode):\n            client = self._get_client(session, node_name)\n            if client:\n                api_name = node._api_name or \"/predict\"\n                if not api_name.startswith(\"/\"):\n                    api_name = \"/\" + api_name\n                call_inputs = {\n                    k: self._wrap_file_input(v)\n                    for k, v in all_inputs.items()\n                    if k in node._input_ports\n                }\n                if node._preprocess:\n                    call_inputs = node._preprocess(call_inputs)\n                raw_result = client.predict(api_name=api_name, **call_inputs)\n                if node._postprocess:\n                    raw_result = self._apply_postprocess(node._postprocess, raw_result)\n                result = self._map_gradio_result(\n                    node, raw_result, hf_token=session.hf_token\n                )\n            else:\n                result = None\n\n        elif isinstance(node, FnNode):\n            fn_kwargs = {}\n            for port_name in node._input_ports:\n                if port_name in all_inputs:\n                    fn_kwargs[port_name] = all_inputs[port_name]\n            if node._preprocess:\n                fn_kwargs = node._preprocess(fn_kwargs)\n            raw_result = node._fn(**fn_kwargs)\n            if node._postprocess:\n                raw_result = self._apply_postprocess(node._postprocess, raw_result)\n            result = self._map_fn_result(node, raw_result)\n\n        elif isinstance(node, InferenceNode):\n            from huggingface_hub import InferenceClient\n\n            if not node._task_fetched:\n                node._fetch_model_info()\n            client = InferenceClient(\n                model=node._model_name_for_hub,\n                provider=node._provider,\n                token=session.hf_token,\n            )\n            inference_inputs = {\n                k: v for k, v in all_inputs.items() if k in node._input_ports\n            }\n            if node._preprocess:\n                inference_inputs = node._preprocess(inference_inputs)\n            raw_result = _call_inference_task(client, node._task, inference_inputs)\n            if node._postprocess:\n                raw_result = self._apply_postprocess(node._postprocess, raw_result)\n            result = self._map_inference_result(node, raw_result)\n\n        elif isinstance(node, InteractionNode):\n            result = all_inputs.get(\n                \"input\",\n                all_inputs.get(node._input_ports[0]) if node._input_ports else None,\n            )\n        elif isinstance(node, InputNode):\n            result = {}\n            for port in node._output_ports:\n                result[port] = all_inputs.get(port)\n\n            return result\n        else:\n            result = None\n\n        return result\n\n    def _execute_variant_node_sync(\n        self,\n        session: ExecutionSession,\n        node_name: str,\n        variant,\n        inputs: dict[str, Any],\n    ) -> Any:\n        all_inputs = {}\n        for port_name, value in variant._fixed_inputs.items():\n            all_inputs[port_name] = value() if callable(value) else value\n        for port_name, component in variant._input_components.items():\n            if hasattr(component, \"value\"):\n                val = component.value\n                if is_file_obj_with_meta(val):\n                    val = val[\"path\"]\n                all_inputs[port_name] = val\n        all_inputs.update(inputs)\n\n        if isinstance(variant, GradioNode):\n            client = self._get_client(session, node_name)\n            if client:\n                api_name = variant._api_name or \"/predict\"\n                if not api_name.startswith(\"/\"):\n                    api_name = \"/\" + api_name\n                call_inputs = {\n                    k: self._wrap_file_input(v)\n                    for k, v in all_inputs.items()\n                    if k in variant._input_ports\n                }\n                if variant._preprocess:\n                    call_inputs = variant._preprocess(call_inputs)\n                raw_result = client.predict(api_name=api_name, **call_inputs)\n                if variant._postprocess:\n                    raw_result = self._apply_postprocess(\n                        variant._postprocess, raw_result\n                    )\n                result = self._map_gradio_result(\n                    variant, raw_result, hf_token=session.hf_token\n                )\n            else:\n                result = None\n\n        elif isinstance(variant, FnNode):\n            fn_kwargs = {}\n            for port_name in variant._input_ports:\n                if port_name in all_inputs:\n                    fn_kwargs[port_name] = all_inputs[port_name]\n            if variant._preprocess:\n                fn_kwargs = variant._preprocess(fn_kwargs)\n            raw_result = variant._fn(**fn_kwargs)\n            if variant._postprocess:\n                raw_result = self._apply_postprocess(variant._postprocess, raw_result)\n            result = self._map_fn_result(variant, raw_result)\n\n        elif isinstance(variant, InferenceNode):\n            from huggingface_hub import InferenceClient\n\n            if not variant._task_fetched:\n                variant._fetch_model_info()\n            client = InferenceClient(\n                model=variant._model_name_for_hub,\n                provider=variant._provider,\n                token=session.hf_token,\n            )\n            inference_inputs = {\n                k: v for k, v in all_inputs.items() if k in variant._input_ports\n            }\n            if variant._preprocess:\n                inference_inputs = variant._preprocess(inference_inputs)\n            raw_result = _call_inference_task(client, variant._task, inference_inputs)\n            if variant._postprocess:\n                raw_result = self._apply_postprocess(variant._postprocess, raw_result)\n            result = self._map_inference_result(variant, raw_result)\n\n        elif isinstance(variant, InputNode):\n            result = {}\n            for port in variant._output_ports:\n                result[port] = all_inputs.get(port)\n            return result\n\n        else:\n            result = None\n\n        return result\n\n    async def execute_node(\n        self,\n        session: ExecutionSession,\n        node_name: str,\n        user_inputs: dict[str, Any] | None = None,\n    ) -> Any:\n        \"\"\"Execute a single node with proper concurrency control.\"\"\"\n        node = self.graph.nodes[node_name]\n        scattered_edges = self._get_scattered_input_edges(node_name)\n\n        if scattered_edges:\n            result = await self._execute_scattered_node(\n                session, node_name, scattered_edges, user_inputs\n            )\n        else:\n            inputs = self._prepare_inputs(session, node_name)\n            if user_inputs:\n                if isinstance(user_inputs, dict):\n                    inputs.update(user_inputs)\n                else:\n                    if node._input_ports:\n                        inputs[node._input_ports[0]] = user_inputs\n                    else:\n                        inputs[\"input\"] = user_inputs\n\n            try:\n                if isinstance(node, (GradioNode, InferenceNode)):\n                    result = await asyncio.to_thread(\n                        self._execute_single_node_sync, session, node_name, inputs\n                    )\n                elif isinstance(node, FnNode):\n                    semaphore = await session.concurrency.get_semaphore(\n                        node._concurrent,\n                        node._concurrency_group,\n                        node._max_concurrent,\n                    )\n                    if semaphore:\n                        async with semaphore:\n                            result = await asyncio.to_thread(\n                                self._execute_single_node_sync,\n                                session,\n                                node_name,\n                                inputs,\n                            )\n                    else:\n                        result = await asyncio.to_thread(\n                            self._execute_single_node_sync, session, node_name, inputs\n                        )\n                else:\n                    result = await asyncio.to_thread(\n                        self._execute_single_node_sync, session, node_name, inputs\n                    )\n            except Exception as e:\n                raise RuntimeError(f\"Error executing node '{node_name}': {e}\")\n\n        session.results[node_name] = result\n        return result\n\n    async def _execute_scattered_node(\n        self,\n        session: ExecutionSession,\n        node_name: str,\n        scattered_edges: list,\n        user_inputs: dict[str, Any] | None = None,\n    ) -> dict[str, list[Any]]:\n        first_edge = scattered_edges[0]\n        source_name = first_edge.source_node._name\n        source_port = first_edge.source_port\n\n        source_result = session.results.get(source_name)\n        if source_result is None:\n            items = []\n        elif isinstance(source_result, dict) and source_port in source_result:\n            items = source_result[source_port]\n        else:\n            items = source_result\n\n        if not isinstance(items, list):\n            items = [items]\n\n        context_inputs = self._prepare_inputs(session, node_name, skip_scattered=True)\n        if user_inputs:\n            context_inputs.update(user_inputs)\n\n        node = self.graph.nodes[node_name]\n\n        async def execute_item(item, idx):\n            item_inputs = dict(context_inputs)\n            for edge in scattered_edges:\n                target_port = edge.target_port\n                item_key = edge.item_key\n                if item_key and isinstance(item, dict):\n                    item_inputs[target_port] = item.get(item_key)\n                else:\n                    item_inputs[target_port] = item\n\n            try:\n                if isinstance(node, (GradioNode, InferenceNode)):\n                    return await asyncio.to_thread(\n                        self._execute_single_node_sync, session, node_name, item_inputs\n                    )\n                elif isinstance(node, FnNode):\n                    semaphore = await session.concurrency.get_semaphore(\n                        node._concurrent,\n                        node._concurrency_group,\n                        node._max_concurrent,\n                    )\n                    if semaphore:\n                        async with semaphore:\n                            return await asyncio.to_thread(\n                                self._execute_single_node_sync,\n                                session,\n                                node_name,\n                                item_inputs,\n                            )\n                    else:\n                        return await asyncio.to_thread(\n                            self._execute_single_node_sync,\n                            session,\n                            node_name,\n                            item_inputs,\n                        )\n                else:\n                    return await asyncio.to_thread(\n                        self._execute_single_node_sync, session, node_name, item_inputs\n                    )\n            except Exception as e:\n                return {\"error\": str(e)}\n\n        if isinstance(node, (GradioNode, InferenceNode)):\n            tasks = [execute_item(item, i) for i, item in enumerate(items)]\n            results = await asyncio.gather(*tasks)\n        else:\n            results = []\n            for i, item in enumerate(items):\n                result = await execute_item(item, i)\n                results.append(result)\n\n        session.scattered_results[node_name] = list(results)\n        return {\"_scattered_results\": list(results), \"_items\": items}\n\n    def _wrap_file_input(self, value: Any) -> Any:\n        from gradio_client import handle_file\n\n        if isinstance(value, FileValue):\n            return handle_file(str(value))\n\n        if isinstance(value, str):\n            if value.startswith(\"data:\"):\n                file_path = self._save_data_url_to_file(value)\n                if file_path:\n                    return handle_file(file_path)\n            elif Path(value).exists():\n                return handle_file(value)\n\n        return value\n\n    def _save_data_url_to_file(self, data_url: str) -> str | None:\n        \"\"\"Convert a base64 data URL to a file and return the path.\"\"\"\n        if not data_url.startswith(\"data:\"):\n            return None\n\n        try:\n            header, encoded = data_url.split(\",\", 1)\n            media_type = header.split(\":\")[1].split(\";\")[0]\n            ext_map = {\n                \"image/png\": \".png\",\n                \"image/jpeg\": \".jpg\",\n                \"image/jpg\": \".jpg\",\n                \"image/gif\": \".gif\",\n                \"image/webp\": \".webp\",\n                \"audio/wav\": \".wav\",\n                \"audio/mpeg\": \".mp3\",\n                \"audio/mp3\": \".mp3\",\n                \"audio/ogg\": \".ogg\",\n                \"audio/webm\": \".webm\",\n                \"video/mp4\": \".mp4\",\n                \"video/webm\": \".webm\",\n            }\n            ext = ext_map.get(media_type, \".bin\")\n            data = base64.b64decode(encoded)\n            file_path = get_daggr_files_dir() / f\"{uuid.uuid4()}{ext}\"\n            file_path.write_bytes(data)\n            return str(file_path)\n        except Exception:\n            return None\n\n    def _apply_postprocess(self, postprocess, raw_result: Any) -> Any:\n        if isinstance(raw_result, (list, tuple)):\n            return postprocess(*raw_result)\n        return postprocess(raw_result)\n\n    def _extract_file_urls(self, data: Any, hf_token: str | None = None) -> Any:\n        def download_and_wrap(file_obj: dict) -> FileValue:\n            url = file_obj.get(\"url\")\n            if url:\n                local_path = _download_file(url, hf_token=hf_token)\n                return FileValue(local_path)\n            path = file_obj.get(\"path\", \"\")\n            return FileValue(path)\n\n        return traverse(data, download_and_wrap, is_file_obj_with_meta)\n\n    def _map_gradio_result(\n        self, node, raw_result: Any, hf_token: str | None = None\n    ) -> dict[str, Any]:\n        if raw_result is None:\n            return {}\n\n        raw_result = self._extract_file_urls(raw_result, hf_token=hf_token)\n\n        output_ports = node._output_ports\n        if not output_ports:\n            return {\"output\": raw_result}\n\n        if isinstance(raw_result, (list, tuple)):\n            result = {}\n            for i, port_name in enumerate(output_ports):\n                if i < len(raw_result):\n                    result[port_name] = raw_result[i]\n                else:\n                    result[port_name] = None\n            return result\n        elif len(output_ports) == 1:\n            return {output_ports[0]: raw_result}\n        else:\n            return {output_ports[0]: raw_result}\n\n    def _map_fn_result(self, node, raw_result: Any) -> dict[str, Any]:\n        if raw_result is None:\n            return {}\n\n        output_ports = node._output_ports\n        if not output_ports:\n            return {\"output\": raw_result}\n\n        if isinstance(raw_result, tuple):\n            result = {}\n            for i, port_name in enumerate(output_ports):\n                if i < len(raw_result):\n                    result[port_name] = raw_result[i]\n                else:\n                    result[port_name] = None\n            return result\n        else:\n            return {output_ports[0]: raw_result}\n\n    def _map_inference_result(self, node, raw_result: Any) -> dict[str, Any]:\n        \"\"\"Map inference API result to output ports.\"\"\"\n        if raw_result is None:\n            return {}\n\n        output_ports = node._output_ports\n        if not output_ports:\n            return {\"output\": raw_result}\n\n        return {output_ports[0]: raw_result}\n\n    async def execute_all(\n        self, session: ExecutionSession, entry_inputs: dict[str, dict[str, Any]]\n    ) -> dict[str, Any]:\n        execution_order = self.graph.get_execution_order()\n        session.results = {}\n\n        for node_name in execution_order:\n            user_input = entry_inputs.get(node_name, {})\n            await self.execute_node(session, node_name, user_input)\n\n        return session.results\n\n\nclass SequentialExecutor:\n    \"\"\"Legacy synchronous executor for backwards compatibility.\n\n    This wraps the AsyncExecutor for use in synchronous contexts like node.test().\n    For production use, prefer AsyncExecutor with proper session management.\n    \"\"\"\n\n    def __init__(self, graph: Graph, hf_token: str | None = None):\n        self.graph = graph\n        self._async_executor = AsyncExecutor(graph)\n        self._session = ExecutionSession(graph, hf_token)\n\n    @property\n    def results(self) -> dict[str, Any]:\n        return self._session.results\n\n    @results.setter\n    def results(self, value: dict[str, Any]):\n        self._session.results = value\n\n    @property\n    def selected_variants(self) -> dict[str, int]:\n        return self._session.selected_variants\n\n    @selected_variants.setter\n    def selected_variants(self, value: dict[str, int]):\n        self._session.selected_variants = value\n\n    def set_hf_token(self, token: str | None):\n        self._session.set_hf_token(token)\n\n    def execute_node(\n        self, node_name: str, user_inputs: dict[str, Any] | None = None\n    ) -> Any:\n        \"\"\"Synchronous wrapper around async execute_node.\"\"\"\n        loop = asyncio.new_event_loop()\n        try:\n            return loop.run_until_complete(\n                self._async_executor.execute_node(self._session, node_name, user_inputs)\n            )\n        finally:\n            loop.close()\n\n    def execute_all(self, entry_inputs: dict[str, dict[str, Any]]) -> dict[str, Any]:\n        \"\"\"Synchronous wrapper around async execute_all.\"\"\"\n        loop = asyncio.new_event_loop()\n        try:\n            return loop.run_until_complete(\n                self._async_executor.execute_all(self._session, entry_inputs)\n            )\n        finally:\n            loop.close()\n"
  },
  {
    "path": "daggr/frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>daggr</title>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n  <link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n  <link rel=\"stylesheet\" href=\"/theme.css\">\n  <style>\n    * { margin: 0; box-sizing: border-box; }\n    body {\n      background: var(--body-background-fill, #000);\n      min-height: 100vh;\n      font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;\n      overflow: hidden;\n      color: var(--body-text-color, #fff);\n    }\n  </style>\n</head>\n<body class=\"dark\">\n  <div id=\"app\"></div>\n  <script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n</html>\n\n"
  },
  {
    "path": "daggr/frontend/package.json",
    "content": "{\n  \"name\": \"daggr-frontend\",\n  \"private\": true,\n  \"version\": \"0.0.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@babylonjs/viewer\": \"^8.47.2\"\n  },\n  \"devDependencies\": {\n    \"@sveltejs/vite-plugin-svelte\": \"^5.0.3\",\n    \"svelte\": \"^5.16.0\",\n    \"vite\": \"^6.0.7\"\n  }\n}\n"
  },
  {
    "path": "daggr/frontend/src/App.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount } from 'svelte';\n\timport { EmbeddedComponent, MapItemsSection, ItemListSection } from './components';\n\timport type { GraphNode, GraphEdge, CanvasData, GradioComponentData } from './types';\n\n\tinterface Sheet {\n\t\tsheet_id: string;\n\t\tname: string;\n\t\tcreated_at: string;\n\t\tupdated_at: string;\n\t}\n\n\tlet canvasEl: HTMLDivElement;\n\tlet transform = $state({ x: 0, y: 0, scale: 1 });\n\tlet isPanning = $state(false);\n\tlet startPan = $state({ x: 0, y: 0 });\n\n\tlet graphData = $state<CanvasData | null>(null);\n\tlet sessionId = $state<string | null>(null);\n\tlet ws: WebSocket | null = null;\n\tlet wsConnected = $state(false);\n\tlet reconnectAttempts = 0;\n\tlet maxReconnectAttempts = 10;\n\tlet isConnecting = false;\n\tlet reconnectTimer: number | null = null;\n\n\tlet inputValues = $state<Record<string, Record<string, any>>>({});\n\tlet runningNodes = $state<Set<string>>(new Set());\n\tlet nodeResults = $state<Record<string, any[]>>({});\n\tlet nodeInputsSnapshots = $state<Record<string, (Record<string, any> | null)[]>>({});\n\tlet selectedResultIndex = $state<Record<string, number>>({});\n\tlet itemListValues = $state<Record<string, Record<number, Record<string, any>>>>({});\n\tlet selectedVariants = $state<Record<string, number>>({});\n\tlet nodeExecutionTimes = $state<Record<string, number>>({});\n\tlet nodeStartTimes = $state<Record<string, number>>({});\n\tlet nodeAvgTimes = $state<Record<string, { total: number; count: number }>>({});\n\tlet nodeErrors = $state<Record<string, string>>({});\n\tlet timerTick = $state(0);\n\tlet hfUser = $state<{ username: string; fullname: string; avatar_url: string } | null>(null);\n\tlet nodeRunModes = $state<Record<string, 'step' | 'toHere'>>({});\n\tlet runModeMenuOpen = $state<string | null>(null);\n\tlet runModeVersion = $state(0);\n\tlet highlightedNodes = $state<Set<string>>(new Set());\n\tlet nodeRunIds = $state<Record<string, string>>({});\n\n\tlet sheets = $state<Sheet[]>([]);\n\tlet currentSheetId = $state<string | null>(null);\n\tlet userId = $state<string | null>(null);\n\tlet canPersist = $state(false);\n\tlet isOnSpaces = $state(false);\n\tlet sheetDropdownOpen = $state(false);\n\tlet editingSheetName = $state(false);\n\tlet editSheetNameValue = $state('');\n\tlet saveDebounceTimer: number | null = null;\n\tlet transformDebounceTimer: number | null = null;\n\n\tlet showLoginTooltip = $state(false);\n\tlet tokenInputValue = $state('');\n\tlet loginLoading = $state(false);\n\tlet loginError = $state('');\n\tlet hasShownPersistencePrompt = $state(false);\n\n\tconst HF_TOKEN_KEY = 'daggr_hf_token';\n\tlet isDark = $state(true);\n\n\tfunction applyTheme(dark: boolean) {\n\t\tif (dark) {\n\t\t\tdocument.documentElement.classList.add('dark');\n\t\t\tdocument.body.classList.add('dark');\n\t\t} else {\n\t\t\tdocument.documentElement.classList.remove('dark');\n\t\t\tdocument.body.classList.remove('dark');\n\t\t}\n\t}\n\n\tfunction toggleTheme() {\n\t\tisDark = !isDark;\n\t\tlocalStorage.setItem('theme', isDark ? 'dark' : 'light');\n\t\tapplyTheme(isDark);\n\t}\n\n\tfunction getStoredToken(): string | null {\n\t\ttry {\n\t\t\treturn localStorage.getItem(HF_TOKEN_KEY);\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tfunction storeToken(token: string) {\n\t\ttry {\n\t\t\tlocalStorage.setItem(HF_TOKEN_KEY, token);\n\t\t} catch {\n\t\t\tconsole.warn('[daggr] Could not store token in localStorage');\n\t\t}\n\t}\n\n\tfunction clearStoredToken() {\n\t\ttry {\n\t\t\tlocalStorage.removeItem(HF_TOKEN_KEY);\n\t\t} catch {\n\t\t\tconsole.warn('[daggr] Could not clear token from localStorage');\n\t\t}\n\t}\n\n\tlet timerInterval: number | null = null;\n\n\tlet nodes = $derived(graphData?.nodes || []);\n\tlet edges = $derived(graphData?.edges || []);\n\tlet currentSheet = $derived(sheets.find(s => s.sheet_id === currentSheetId));\n\n\tfunction startTimer() {\n\t\tif (timerInterval) return;\n\t\ttimerInterval = window.setInterval(() => {\n\t\t\ttimerTick++;\n\t\t}, 100);\n\t}\n\n\tfunction stopTimerIfNoRunning() {\n\t\tif (runningNodes.size === 0 && timerInterval) {\n\t\t\tclearInterval(timerInterval);\n\t\t\ttimerInterval = null;\n\t\t}\n\t}\n\n\tconst NODE_WIDTH = 280;\n\tconst HEADER_HEIGHT = 36;\n\tconst HEADER_BORDER = 1;\n\tconst BODY_PADDING_TOP = 8;\n\tconst PORT_ROW_HEIGHT = 22;\n\tconst EMBEDDED_COMPONENT_HEIGHT = 60;\n\n\tfunction generateSessionId(): string {\n\t\treturn 'session_' + Math.random().toString(36).slice(2) + Date.now().toString(36);\n\t}\n\n\tasync function fetchUserInfo() {\n\t\ttry {\n\t\t\tconst token = getStoredToken();\n\t\t\tconst headers: Record<string, string> = {};\n\t\t\tif (token) {\n\t\t\t\theaders['Authorization'] = `Bearer ${token}`;\n\t\t\t}\n\t\t\tconst response = await fetch('/api/user_info', { headers });\n\t\t\tif (response.ok) {\n\t\t\t\tconst data = await response.json();\n\t\t\t\thfUser = data.hf_user;\n\t\t\t\tuserId = data.user_id;\n\t\t\t\tcanPersist = data.can_persist;\n\t\t\t\tisOnSpaces = data.is_on_spaces;\n\t\t\t\treturn data;\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.log('[daggr] Could not fetch user info');\n\t\t}\n\t\treturn null;\n\t}\n\n\tasync function handleLogin() {\n\t\tif (!tokenInputValue.trim()) {\n\t\t\tloginError = 'Please enter a token';\n\t\t\treturn;\n\t\t}\n\t\tloginLoading = true;\n\t\tloginError = '';\n\t\ttry {\n\t\t\tconst response = await fetch('/api/auth/login', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tbody: JSON.stringify({ token: tokenInputValue.trim() })\n\t\t\t});\n\t\t\tconst data = await response.json();\n\t\t\tif (response.ok && data.success) {\n\t\t\t\tstoreToken(tokenInputValue.trim());\n\t\t\t\thfUser = data.hf_user;\n\t\t\t\tshowLoginTooltip = false;\n\t\t\t\ttokenInputValue = '';\n\t\t\t\tawait fetchUserInfo();\n\t\t\t\tawait fetchSheets();\n\t\t\t\t\n\t\t\t\tif (sheets.length > 0) {\n\t\t\t\t\tcurrentSheetId = sheets[0].sheet_id;\n\t\t\t\t} else if (canPersist) {\n\t\t\t\t\tawait createSheet('Sheet 1');\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif (ws && wsConnected) {\n\t\t\t\t\tconst token = getStoredToken();\n\t\t\t\t\tws.send(JSON.stringify({ action: 'set_sheet', sheet_id: currentSheetId, hf_token: token }));\n\t\t\t\t\tws.send(JSON.stringify({ action: 'get_graph', sheet_id: currentSheetId, hf_token: token }));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tloginError = data.error || 'Invalid token';\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tloginError = 'Failed to verify token';\n\t\t} finally {\n\t\t\tloginLoading = false;\n\t\t}\n\t}\n\n\tasync function handleLogout() {\n\t\tclearStoredToken();\n\t\thfUser = null;\n\t\tawait fetchUserInfo();\n\t\tawait fetchSheets();\n\t\t\n\t\tif (sheets.length > 0) {\n\t\t\tcurrentSheetId = sheets[0].sheet_id;\n\t\t} else {\n\t\t\tcurrentSheetId = null;\n\t\t}\n\t\t\n\t\tif (ws && wsConnected) {\n\t\t\tws.send(JSON.stringify({ action: 'set_sheet', sheet_id: currentSheetId, hf_token: null }));\n\t\t\tws.send(JSON.stringify({ action: 'get_graph', sheet_id: currentSheetId, hf_token: null }));\n\t\t}\n\t}\n\n\tasync function fetchSheets() {\n\t\tif (!canPersist) return;\n\t\ttry {\n\t\t\tconst token = getStoredToken();\n\t\t\tconst headers: Record<string, string> = {};\n\t\t\tif (token) {\n\t\t\t\theaders['Authorization'] = `Bearer ${token}`;\n\t\t\t}\n\t\t\tconst response = await fetch('/api/sheets', { headers });\n\t\t\tif (response.ok) {\n\t\t\t\tconst data = await response.json();\n\t\t\t\tsheets = data.sheets || [];\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.log('[daggr] Could not fetch sheets');\n\t\t}\n\t}\n\n\tasync function createSheet(name?: string) {\n\t\tif (!canPersist) return;\n\t\ttry {\n\t\t\tconst token = getStoredToken();\n\t\t\tconst headers: Record<string, string> = { 'Content-Type': 'application/json' };\n\t\t\tif (token) {\n\t\t\t\theaders['Authorization'] = `Bearer ${token}`;\n\t\t\t}\n\t\t\tconst response = await fetch('/api/sheets', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders,\n\t\t\t\tbody: JSON.stringify({ name })\n\t\t\t});\n\t\t\tif (response.ok) {\n\t\t\t\tconst data = await response.json();\n\t\t\t\tconst newSheet = data.sheet;\n\t\t\t\tsheets = [newSheet, ...sheets];\n\t\t\t\tawait selectSheet(newSheet.sheet_id);\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('[daggr] Failed to create sheet:', e);\n\t\t}\n\t}\n\n\tasync function renameSheet(sheetId: string, newName: string) {\n\t\ttry {\n\t\t\tconst token = getStoredToken();\n\t\t\tconst headers: Record<string, string> = { 'Content-Type': 'application/json' };\n\t\t\tif (token) {\n\t\t\t\theaders['Authorization'] = `Bearer ${token}`;\n\t\t\t}\n\t\t\tconst response = await fetch(`/api/sheets/${sheetId}`, {\n\t\t\t\tmethod: 'PATCH',\n\t\t\t\theaders,\n\t\t\t\tbody: JSON.stringify({ name: newName })\n\t\t\t});\n\t\t\tif (response.ok) {\n\t\t\t\tsheets = sheets.map(s => s.sheet_id === sheetId ? { ...s, name: newName } : s);\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('[daggr] Failed to rename sheet:', e);\n\t\t}\n\t}\n\n\tasync function deleteSheet(sheetId: string) {\n\t\tif (!confirm('Delete this sheet and all its data?')) return;\n\t\ttry {\n\t\t\tconst token = getStoredToken();\n\t\t\tconst headers: Record<string, string> = {};\n\t\t\tif (token) {\n\t\t\t\theaders['Authorization'] = `Bearer ${token}`;\n\t\t\t}\n\t\t\tconst response = await fetch(`/api/sheets/${sheetId}`, { method: 'DELETE', headers });\n\t\t\tif (response.ok) {\n\t\t\t\tsheets = sheets.filter(s => s.sheet_id !== sheetId);\n\t\t\t\tif (currentSheetId === sheetId) {\n\t\t\t\t\tif (sheets.length > 0) {\n\t\t\t\t\t\tawait selectSheet(sheets[0].sheet_id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tawait createSheet();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('[daggr] Failed to delete sheet:', e);\n\t\t}\n\t}\n\n\tasync function selectSheet(sheetId: string) {\n\t\tcurrentSheetId = sheetId;\n\t\tnodeResults = {};\n\t\tselectedResultIndex = {};\n\t\tinputValues = {};\n\t\titemListValues = {};\n\t\tselectedVariants = {};\n\t\trunningNodes = new Set();\n\t\tnodeStartTimes = {};\n\t\tnodeExecutionTimes = {};\n\t\tnodeErrors = {};\n\t\tif (timerInterval) {\n\t\t\tclearInterval(timerInterval);\n\t\t\ttimerInterval = null;\n\t\t}\n\t\tif (transformDebounceTimer) {\n\t\t\tclearTimeout(transformDebounceTimer);\n\t\t\ttransformDebounceTimer = null;\n\t\t}\n\t\ttimerTick = 0;\n\t\ttransform = { x: 0, y: 0, scale: 1 };\n\t\t\n\t\tif (ws && wsConnected) {\n\t\t\tconst token = getStoredToken();\n\t\t\tws.send(JSON.stringify({ action: 'set_sheet', sheet_id: sheetId, hf_token: token }));\n\t\t\tws.send(JSON.stringify({ action: 'get_graph', sheet_id: sheetId, hf_token: token }));\n\t\t}\n\t\t\n\t\tsheetDropdownOpen = false;\n\t}\n\n\tfunction connectWebSocket() {\n\t\tif (isConnecting) return;\n\t\tif (reconnectAttempts >= maxReconnectAttempts) {\n\t\t\tconsole.error('[daggr] Max reconnection attempts reached');\n\t\t\treturn;\n\t\t}\n\t\t\n\t\tisConnecting = true;\n\t\t\n\t\tif (!sessionId) {\n\t\t\tsessionId = generateSessionId();\n\t\t}\n\t\t\n\t\tconst protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n\t\tconst wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}`;\n\t\t\n\t\tconsole.log('[daggr] Connecting to', wsUrl);\n\t\t\n\t\ttry {\n\t\t\tws = new WebSocket(wsUrl);\n\t\t} catch (e) {\n\t\t\tconsole.error('[daggr] Failed to create WebSocket:', e);\n\t\t\tisConnecting = false;\n\t\t\tscheduleReconnect();\n\t\t\treturn;\n\t\t}\n\t\t\n\t\tws.onopen = async () => {\n\t\t\tconsole.log('[daggr] WebSocket connected');\n\t\t\tisConnecting = false;\n\t\t\twsConnected = true;\n\t\t\treconnectAttempts = 0;\n\t\t\t\n\t\t\tconst token = getStoredToken();\n\t\t\tif (canPersist && currentSheetId) {\n\t\t\t\tws?.send(JSON.stringify({ action: 'get_graph', sheet_id: currentSheetId, hf_token: token }));\n\t\t\t} else {\n\t\t\t\tws?.send(JSON.stringify({ action: 'get_graph', hf_token: token }));\n\t\t\t}\n\t\t};\n\t\t\n\t\tws.onmessage = (event) => {\n\t\t\tconst data = JSON.parse(event.data);\n\t\t\thandleMessage(data);\n\t\t};\n\t\t\n\t\tws.onclose = () => {\n\t\t\tisConnecting = false;\n\t\t\twsConnected = false;\n\t\t\tscheduleReconnect();\n\t\t};\n\t\t\n\t\tws.onerror = () => {\n\t\t\tconsole.error('[daggr] WebSocket error');\n\t\t\tisConnecting = false;\n\t\t};\n\t}\n\t\n\tfunction scheduleReconnect() {\n\t\tif (reconnectTimer) return;\n\t\treconnectAttempts++;\n\t\tconst delay = reconnectAttempts === 1 ? 0 : reconnectAttempts <= 5 ? 50 : Math.min(1000 * Math.pow(2, reconnectAttempts - 5), 30000);\n\t\tconsole.log(`[daggr] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);\n\t\treconnectTimer = window.setTimeout(() => {\n\t\t\treconnectTimer = null;\n\t\t\tconnectWebSocket();\n\t\t}, delay);\n\t}\n\n\tfunction handleMessage(data: any) {\n\t\tif (data.type === 'graph') {\n\t\t\tconst newUserId = data.data.user_id;\n\t\t\tconst newSheetId = data.data.sheet_id;\n\t\t\tconst userOrSheetChanged = newUserId !== userId || newSheetId !== currentSheetId;\n\t\t\t\n\t\t\tif (userOrSheetChanged) {\n\t\t\t\tnodeResults = {};\n\t\t\t\tnodeInputsSnapshots = {};\n\t\t\t\tselectedResultIndex = {};\n\t\t\t\tnodeErrors = {};\n\t\t\t\tinputValues = {};\n\t\t\t\titemListValues = {};\n\t\t\t\tselectedVariants = {};\n\t\t\t\tnodeExecutionTimes = {};\n\t\t\t}\n\t\t\t\n\t\t\tgraphData = data.data;\n\t\t\tuserId = newUserId;\n\t\t\t\n\t\t\tif (newSheetId) {\n\t\t\t\tcurrentSheetId = newSheetId;\n\t\t\t}\n\t\t\t\n\t\t\tif (data.data.nodes) {\n\t\t\t\tlet hasNewErrors = false;\n\t\t\t\tfor (const node of data.data.nodes) {\n\t\t\t\t\tif (node.validation_error) {\n\t\t\t\t\t\tnodeErrors[node.name] = node.validation_error;\n\t\t\t\t\t\thasNewErrors = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (hasNewErrors) {\n\t\t\t\t\tnodeErrors = { ...nodeErrors };\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif (data.data.persisted_results) {\n\t\t\t\tfor (const [nodeName, results] of Object.entries(data.data.persisted_results as Record<string, any[]>)) {\n\t\t\t\t\tif (results && results.length > 0) {\n\t\t\t\t\t\tconst node = data.data.nodes?.find((n: GraphNode) => n.name === nodeName);\n\t\t\t\t\t\tif (node && node.output_components?.length > 0) {\n\t\t\t\t\t\t\tconst snapshots: (Record<string, any> | null)[] = [];\n\t\t\t\t\t\t\tnodeResults[nodeName] = results.map((entry: any) => {\n\t\t\t\t\t\t\t\tconst result = entry?.result !== undefined ? entry.result : entry;\n\t\t\t\t\t\t\t\tconst inputsSnapshot = entry?.inputs_snapshot || null;\n\t\t\t\t\t\t\t\tsnapshots.push(inputsSnapshot);\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\treturn node.output_components.map((comp: GradioComponentData) => {\n\t\t\t\t\t\t\t\t\tif (result === null || result === undefined) {\n\t\t\t\t\t\t\t\t\t\treturn { ...comp, value: comp.value };\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tif (typeof result !== 'object' || Array.isArray(result)) {\n\t\t\t\t\t\t\t\t\t\tconst expectedKeys = node.output_components.map((c: GradioComponentData) => c.port_name).join(', ');\n\t\t\t\t\t\t\t\t\t\tnodeErrors[nodeName] = `Function must return a dict with keys: {${expectedKeys}}. Got ${Array.isArray(result) ? 'list' : typeof result} instead.`;\n\t\t\t\t\t\t\t\t\t\treturn { ...comp, value: comp.value };\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tif (!(comp.port_name in result)) {\n\t\t\t\t\t\t\t\t\t\tconst expectedKeys = node.output_components.map((c: GradioComponentData) => c.port_name).join(', ');\n\t\t\t\t\t\t\t\t\t\tconst gotKeys = Object.keys(result).join(', ');\n\t\t\t\t\t\t\t\t\t\tnodeErrors[nodeName] = `Missing key \"${comp.port_name}\" in return value. Expected: {${expectedKeys}}, got: {${gotKeys}}`;\n\t\t\t\t\t\t\t\t\t\treturn { ...comp, value: comp.value };\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\treturn { ...comp, value: result[comp.port_name] };\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tnodeInputsSnapshots[nodeName] = snapshots;\n\t\t\t\t\t\t\tselectedResultIndex[nodeName] = nodeResults[nodeName].length - 1;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif (data.data.inputs) {\n\t\t\t\tinputValues = data.data.inputs;\n\t\t\t\tfor (const [nodeId, nodeInputs] of Object.entries(inputValues)) {\n\t\t\t\t\tconst variant = (nodeInputs as Record<string, any>)['_selected_variant'];\n\t\t\t\t\tif (variant !== undefined) {\n\t\t\t\t\t\tselectedVariants[nodeId] = variant;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tif (data.data.transform) {\n\t\t\t\ttransform = {\n\t\t\t\t\tx: data.data.transform.x ?? 0,\n\t\t\t\t\ty: data.data.transform.y ?? 0,\n\t\t\t\t\tscale: data.data.transform.scale ?? 1\n\t\t\t\t};\n\t\t\t}\n\t\t} else if (data.type === 'node_started') {\n\t\t\tconst startedNode = data.started_node;\n\t\t\tif (startedNode) {\n\t\t\t\trunningNodes.add(startedNode);\n\t\t\t\trunningNodes = new Set(runningNodes);\n\t\t\t\tif (data.run_id) {\n\t\t\t\t\tnodeRunIds[startedNode] = data.run_id;\n\t\t\t\t}\n\t\t\t\tnodeStartTimes[startedNode] = Date.now();\n\t\t\t\tdelete nodeErrors[startedNode];\n\t\t\t\tstartTimer();\n\t\t\t}\n\t\t} else if (data.type === 'cancelled') {\n\t\t\tconst cancelledRunId = data.run_id;\n\t\t\tfor (const [nodeName, runId] of Object.entries(nodeRunIds)) {\n\t\t\t\tif (runId === cancelledRunId) {\n\t\t\t\t\trunningNodes.delete(nodeName);\n\t\t\t\t\tdelete nodeStartTimes[nodeName];\n\t\t\t\t\tdelete nodeRunIds[nodeName];\n\t\t\t\t}\n\t\t\t}\n\t\t\trunningNodes = new Set(runningNodes);\n\t\t\tstopTimerIfNoRunning();\n\t\t} else if (data.type === 'error' && data.error) {\n\t\t\tconsole.error('[daggr] server error:', data.error);\n\t\t\tconst errorNode = data.node || data.completed_node;\n\t\t\tif (errorNode) {\n\t\t\t\tnodeErrors[errorNode] = data.error;\n\t\t\t}\n\t\t\tconst nodesToClear = data.nodes_to_clear || (errorNode ? [errorNode] : []);\n\t\t\tfor (const nodeName of nodesToClear) {\n\t\t\t\tdelete nodeStartTimes[nodeName];\n\t\t\t\tdelete nodeRunIds[nodeName];\n\t\t\t\trunningNodes.delete(nodeName);\n\t\t\t}\n\t\t\trunningNodes = new Set(runningNodes);\n\t\t\tstopTimerIfNoRunning();\n\t\t} else if (data.type === 'node_complete' || data.type === 'error') {\n\t\t\tconst completedNode = data.completed_node;\n\t\t\t\n\t\t\tif (completedNode) {\n\t\t\t\trunningNodes.delete(completedNode);\n\t\t\t\trunningNodes = new Set(runningNodes);\n\t\t\t\tdelete nodeRunIds[completedNode];\n\t\t\t}\n\t\t\t\n\t\t\tif (completedNode && data.execution_time_ms != null) {\n\t\t\t\tnodeExecutionTimes[completedNode] = data.execution_time_ms;\n\t\t\t\tdelete nodeStartTimes[completedNode];\n\t\t\t\t\n\t\t\t\tif (!nodeAvgTimes[completedNode]) {\n\t\t\t\t\tnodeAvgTimes[completedNode] = { total: 0, count: 0 };\n\t\t\t\t}\n\t\t\t\tnodeAvgTimes[completedNode].total += data.execution_time_ms;\n\t\t\t\tnodeAvgTimes[completedNode].count++;\n\t\t\t\t\n\t\t\t\tstopTimerIfNoRunning();\n\t\t\t}\n\t\t\t\n\t\t\tif (data.nodes) {\n\t\t\t\tgraphData = { ...graphData!, nodes: data.nodes, edges: data.edges || graphData!.edges };\n\t\t\t\t\n\t\t\t\tlet hasNewErrors = false;\n\t\t\t\tfor (const node of data.nodes) {\n\t\t\t\t\tif (node.validation_error) {\n\t\t\t\t\t\tnodeErrors[node.name] = node.validation_error;\n\t\t\t\t\t\thasNewErrors = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (hasNewErrors) {\n\t\t\t\t\tnodeErrors = { ...nodeErrors };\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\tif (completedNode) {\n\t\t\t\t\tconst node = data.nodes?.find((n: GraphNode) => n.name === completedNode);\n\t\t\t\t\tif (node && node.output_components?.length > 0) {\n\t\t\t\t\t\tconst hasResult = node.output_components.some((c: GradioComponentData) => c.value != null);\n\t\t\t\t\t\tif (hasResult) {\n\t\t\t\t\t\t\tif (!nodeResults[completedNode]) {\n\t\t\t\t\t\t\t\tnodeResults[completedNode] = [];\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (!nodeInputsSnapshots[completedNode]) {\n\t\t\t\t\t\t\t\tnodeInputsSnapshots[completedNode] = [];\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconst resultSnapshot = node.output_components.map((c: GradioComponentData) => ({ ...c }));\n\t\t\t\t\t\t\tnodeResults[completedNode] = [...nodeResults[completedNode], resultSnapshot];\n\t\t\t\t\t\t\tconst snapshot = data.inputs || data.selected_results ? {\n\t\t\t\t\t\t\t\tinputs: data.inputs || {},\n\t\t\t\t\t\t\t\tselected_results: data.selected_results || {},\n\t\t\t\t\t\t\t} : null;\n\t\t\t\t\t\t\tnodeInputsSnapshots[completedNode] = [...nodeInputsSnapshots[completedNode], snapshot];\n\t\t\t\t\t\t\tselectedResultIndex[completedNode] = nodeResults[completedNode].length - 1;\n\n\t\t\t\t\t\t\tif (isOnSpaces && !hfUser && !hasShownPersistencePrompt) {\n\t\t\t\t\t\t\t\thasShownPersistencePrompt = true;\n\t\t\t\t\t\t\t\tshowLoginTooltip = true;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tonMount(() => {\n\t\tconst savedTheme = localStorage.getItem('theme');\n\t\tconst prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n\t\tisDark = savedTheme ? savedTheme === 'dark' : prefersDark;\n\t\tapplyTheme(isDark);\n\n\t\tasync function initialize() {\n\t\t\tawait fetchUserInfo();\n\t\t\t\n\t\t\tif (canPersist) {\n\t\t\t\tawait fetchSheets();\n\t\t\t\tif (sheets.length === 0) {\n\t\t\t\t\tawait createSheet();\n\t\t\t\t} else {\n\t\t\t\t\tcurrentSheetId = sheets[0].sheet_id;\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tconnectWebSocket();\n\t\t}\n\t\t\n\t\tinitialize();\n\t\t\n\t\treturn () => {\n\t\t\tif (reconnectTimer) {\n\t\t\t\tclearTimeout(reconnectTimer);\n\t\t\t\treconnectTimer = null;\n\t\t\t}\n\t\t\tif (timerInterval) {\n\t\t\t\tclearInterval(timerInterval);\n\t\t\t\ttimerInterval = null;\n\t\t\t}\n\t\t\tif (saveDebounceTimer) {\n\t\t\t\tclearTimeout(saveDebounceTimer);\n\t\t\t\tsaveDebounceTimer = null;\n\t\t\t}\n\t\t\tif (transformDebounceTimer) {\n\t\t\t\tclearTimeout(transformDebounceTimer);\n\t\t\t\ttransformDebounceTimer = null;\n\t\t\t}\n\t\t\tif (ws) {\n\t\t\t\tws.onclose = null;\n\t\t\t\tws.onerror = null;\n\t\t\t\tws.close();\n\t\t\t\tws = null;\n\t\t\t}\n\t\t};\n\t});\n\n\tfunction getAncestors(nodeName: string): string[] {\n\t\tconst ancestors = new Set<string>();\n\t\tconst toVisit = [nodeName];\n\t\t\n\t\twhile (toVisit.length > 0) {\n\t\t\tconst current = toVisit.pop()!;\n\t\t\tfor (const edge of edges) {\n\t\t\t\tif (edge.to_node === current.replace(/ /g, '_').replace(/-/g, '_')) {\n\t\t\t\t\tconst sourceNode = nodes.find(n => n.id === edge.from_node);\n\t\t\t\t\tif (sourceNode && !ancestors.has(sourceNode.name) && !sourceNode.is_input_node) {\n\t\t\t\t\t\tancestors.add(sourceNode.name);\n\t\t\t\t\t\ttoVisit.push(sourceNode.name);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t\n\t\treturn Array.from(ancestors);\n\t}\n\n\tfunction debounceSaveInput(nodeId: string, portName: string, value: any) {\n\t\tif (!canPersist || !currentSheetId) return;\n\t\t\n\t\tif (saveDebounceTimer) {\n\t\t\tclearTimeout(saveDebounceTimer);\n\t\t}\n\t\t\n\t\tsaveDebounceTimer = window.setTimeout(() => {\n\t\t\tif (ws && wsConnected) {\n\t\t\t\tws.send(JSON.stringify({\n\t\t\t\t\taction: 'save_input',\n\t\t\t\t\tnode_id: nodeId,\n\t\t\t\t\tport_name: portName,\n\t\t\t\t\tvalue: value\n\t\t\t\t}));\n\t\t\t}\n\t\t}, 500);\n\t}\n\n\tfunction debounceSaveTransform() {\n\t\tif (!canPersist || !currentSheetId) return;\n\t\t\n\t\tif (transformDebounceTimer) {\n\t\t\tclearTimeout(transformDebounceTimer);\n\t\t}\n\t\t\n\t\ttransformDebounceTimer = window.setTimeout(() => {\n\t\t\tif (ws && wsConnected) {\n\t\t\t\tws.send(JSON.stringify({\n\t\t\t\t\taction: 'save_transform',\n\t\t\t\t\tx: transform.x,\n\t\t\t\t\ty: transform.y,\n\t\t\t\t\tscale: transform.scale\n\t\t\t\t}));\n\t\t\t}\n\t\t}, 300);\n\t}\n\n\tasync function handleInputChange(nodeId: string, portName: string, value: any) {\n\t\tif (!inputValues[nodeId]) {\n\t\t\tinputValues[nodeId] = {};\n\t\t}\n\t\tif (value instanceof Blob || value instanceof File) {\n\t\t\tconst dataUrl = await blobToDataUrl(value);\n\t\t\tinputValues[nodeId][portName] = dataUrl;\n\t\t\tdebounceSaveInput(nodeId, portName, dataUrl);\n\t\t} else {\n\t\t\tinputValues[nodeId][portName] = value;\n\t\t\tdebounceSaveInput(nodeId, portName, value);\n\t\t}\n\t}\n\n\tfunction blobToDataUrl(blob: Blob): Promise<string> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst reader = new FileReader();\n\t\t\treader.onload = () => resolve(reader.result as string);\n\t\t\treader.onerror = reject;\n\t\t\treader.readAsDataURL(blob);\n\t\t});\n\t}\n\n\tfunction getComponentValue(node: GraphNode, comp: GradioComponentData): any {\n\t\tconst nodeInputs = inputValues[node.id];\n\t\tif (nodeInputs && comp.port_name in nodeInputs) {\n\t\t\treturn nodeInputs[comp.port_name];\n\t\t}\n\t\treturn comp.value ?? '';\n\t}\n\n\tfunction handleItemListChange(nodeId: string, itemIndex: number, fieldName: string, value: any) {\n\t\tif (!itemListValues[nodeId]) {\n\t\t\titemListValues[nodeId] = {};\n\t\t}\n\t\tif (!itemListValues[nodeId][itemIndex]) {\n\t\t\titemListValues[nodeId][itemIndex] = {};\n\t\t}\n\t\titemListValues[nodeId][itemIndex][fieldName] = value;\n\t}\n\n\tfunction getItemListValue(nodeId: string, itemIndex: number, fieldName: string): any {\n\t\tconst edited = itemListValues[nodeId]?.[itemIndex]?.[fieldName];\n\t\tif (edited !== undefined) return edited;\n\t\tconst node = nodes.find(n => n.id === nodeId);\n\t\tconst item = node?.item_list_items?.find(i => i.index === itemIndex);\n\t\treturn item?.fields?.[fieldName] ?? '';\n\t}\n\n\tfunction handleVariantSelect(nodeId: string, variantIndex: number) {\n\t\tselectedVariants[nodeId] = variantIndex;\n\t\tif (!inputValues[nodeId]) {\n\t\t\tinputValues[nodeId] = {};\n\t\t}\n\t\tinputValues[nodeId]['_selected_variant'] = variantIndex;\n\t\t\n\t\tif (ws && wsConnected) {\n\t\t\tws.send(JSON.stringify({\n\t\t\t\taction: 'save_variant_selection',\n\t\t\t\tnode_id: nodeId,\n\t\t\t\tvariant_index: variantIndex\n\t\t\t}));\n\t\t}\n\t}\n\n\tfunction getSelectedVariant(node: GraphNode): number {\n\t\tif (selectedVariants[node.id] !== undefined) {\n\t\t\treturn selectedVariants[node.id];\n\t\t}\n\t\treturn node.selected_variant ?? 0;\n\t}\n\n\tfunction getComponentsToRender(node: GraphNode): GradioComponentData[] {\n\t\tif (node.is_input_node && node.input_components?.length) {\n\t\t\treturn node.input_components;\n\t\t}\n\t\treturn getSelectedResults(node);\n\t}\n\n\tfunction hasUserProvidedOutput(node: GraphNode): boolean {\n\t\tif (!node.output_components || node.output_components.length === 0) return false;\n\t\tconst nodeInputs = inputValues[node.id];\n\t\tif (!nodeInputs) return false;\n\t\tfor (const comp of node.output_components) {\n\t\t\tif (nodeInputs[comp.port_name] != null) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tfunction getNodeHeight(node: GraphNode): number {\n\t\tconst portRows = Math.max(node.inputs.length, node.outputs.length, 1);\n\t\tconst componentsToRender = getComponentsToRender(node);\n\t\tconst embeddedHeight = componentsToRender.length * EMBEDDED_COMPONENT_HEIGHT;\n\t\treturn HEADER_HEIGHT + HEADER_BORDER + BODY_PADDING_TOP + (portRows * PORT_ROW_HEIGHT) + embeddedHeight + BODY_PADDING_TOP;\n\t}\n\n\tlet nodeMap = $derived.by(() => {\n\t\tconst map = new Map<string, GraphNode>();\n\t\tfor (const node of nodes) {\n\t\t\tmap.set(node.id, node);\n\t\t}\n\t\treturn map;\n\t});\n\n\tfunction getPortY(portIndex: number): number {\n\t\treturn HEADER_HEIGHT + HEADER_BORDER + BODY_PADDING_TOP + (portIndex * PORT_ROW_HEIGHT) + (PORT_ROW_HEIGHT / 2);\n\t}\n\n\tlet edgePaths = $derived.by(() => {\n\t\tconst paths: { \n\t\t\tid: string; \n\t\t\td: string; \n\t\t\tis_scattered: boolean; \n\t\t\tis_gathered: boolean;\n\t\t\tisStale: boolean;\n\t\t\tforkPaths?: string[];\n\t\t\tfromNodeName: string;\n\t\t\ttoNodeName: string;\n\t\t}[] = [];\n\t\t\n\t\tfor (const edge of edges) {\n\t\t\tconst fromNode = nodeMap.get(edge.from_node);\n\t\t\tconst toNode = nodeMap.get(edge.to_node);\n\t\t\t\n\t\t\tif (!fromNode || !toNode) continue;\n\n\t\t\tconst fromPortIdx = fromNode.outputs.indexOf(edge.from_port);\n\t\t\tconst toPortIdx = toNode.inputs.findIndex(p => p.name === edge.to_port);\n\n\t\t\tif (fromPortIdx === -1 || toPortIdx === -1) continue;\n\n\t\t\tconst fromPortY = getPortY(fromPortIdx);\n\t\t\tconst toPortY = getPortY(toPortIdx);\n\n\t\t\tconst x1 = fromNode.x + NODE_WIDTH;\n\t\t\tconst y1 = fromNode.y + fromPortY;\n\t\t\tconst x2 = toNode.x;\n\t\t\tconst y2 = toNode.y + toPortY;\n\n\t\t\tconst dx = Math.abs(x2 - x1);\n\t\t\tconst cp = Math.max(dx * 0.4, 50);\n\n\t\t\tconst is_scattered = edge.is_scattered || false;\n\t\t\tconst is_gathered = edge.is_gathered || false;\n\n\t\t\tconst toNodeSelectedIdx = selectedResultIndex[toNode.name];\n\t\t\tconst toNodeSnapshot = nodeInputsSnapshots[toNode.name]?.[toNodeSelectedIdx];\n\t\t\t\n\t\t\tlet isStale = false;\n\t\t\tif (toNodeSnapshot == null) {\n\t\t\t\tisStale = true;\n\t\t\t} else {\n\t\t\t\tif (selectedResultIndex[fromNode.name] !== toNodeSnapshot.selected_results?.[fromNode.name]) {\n\t\t\t\t\tisStale = true;\n\t\t\t\t}\n\t\t\t\tif (JSON.stringify(inputValues[fromNode.id]) !== JSON.stringify(toNodeSnapshot.inputs?.[fromNode.id])) {\n\t\t\t\t\tisStale = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet forkPaths: string[] = [];\n\n\t\t\tif (is_scattered) {\n\t\t\t\tconst forkStart = x2 - 30;\n\t\t\t\tconst forkSpread = 8;\n\t\t\t\tconst d = `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${forkStart - 20} ${y2}, ${forkStart} ${y2}`;\n\t\t\t\tforkPaths = [\n\t\t\t\t\t`M ${forkStart} ${y2} L ${x2} ${y2 - forkSpread}`,\n\t\t\t\t\t`M ${forkStart} ${y2} L ${x2} ${y2}`,\n\t\t\t\t\t`M ${forkStart} ${y2} L ${x2} ${y2 + forkSpread}`,\n\t\t\t\t];\n\t\t\t\tpaths.push({ id: edge.id, d, is_scattered, is_gathered, isStale, forkPaths, fromNodeName: fromNode.name, toNodeName: toNode.name });\n\t\t\t} else if (is_gathered) {\n\t\t\t\tconst forkEnd = x1 + 30;\n\t\t\t\tconst forkSpread = 8;\n\t\t\t\tforkPaths = [\n\t\t\t\t\t`M ${x1} ${y1 - forkSpread} L ${forkEnd} ${y1}`,\n\t\t\t\t\t`M ${x1} ${y1} L ${forkEnd} ${y1}`,\n\t\t\t\t\t`M ${x1} ${y1 + forkSpread} L ${forkEnd} ${y1}`,\n\t\t\t\t];\n\t\t\t\tconst d = `M ${forkEnd} ${y1} C ${forkEnd + cp - 30} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}`;\n\t\t\t\tpaths.push({ id: edge.id, d, is_scattered, is_gathered, isStale, forkPaths, fromNodeName: fromNode.name, toNodeName: toNode.name });\n\t\t\t} else {\n\t\t\t\tconst d = `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}`;\n\t\t\t\tpaths.push({ id: edge.id, d, is_scattered, is_gathered, isStale, fromNodeName: fromNode.name, toNodeName: toNode.name });\n\t\t\t}\n\t\t}\n\t\t\n\t\treturn paths;\n\t});\n\n\tfunction zoomToFit() {\n\t\tif (nodes.length === 0 || !canvasEl) return;\n\n\t\tconst padding = 40;\n\t\tconst canvasRect = canvasEl.getBoundingClientRect();\n\t\tconst canvasWidth = canvasRect.width;\n\t\tconst canvasHeight = canvasRect.height;\n\n\t\tlet minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n\t\tfor (const node of nodes) {\n\t\t\tconst nodeHeight = getNodeHeight(node);\n\t\t\tminX = Math.min(minX, node.x);\n\t\t\tminY = Math.min(minY, node.y);\n\t\t\tmaxX = Math.max(maxX, node.x + NODE_WIDTH);\n\t\t\tmaxY = Math.max(maxY, node.y + nodeHeight);\n\t\t}\n\n\t\tconst contentWidth = maxX - minX;\n\t\tconst contentHeight = maxY - minY;\n\n\t\tconst scaleX = (canvasWidth - padding * 2) / contentWidth;\n\t\tconst scaleY = (canvasHeight - padding * 2) / contentHeight;\n\t\tconst newScale = Math.min(scaleX, scaleY, 1.5);\n\n\t\tconst centerX = (minX + maxX) / 2;\n\t\tconst centerY = (minY + maxY) / 2;\n\t\tconst newX = canvasWidth / 2 - centerX * newScale;\n\t\tconst newY = canvasHeight / 2 - centerY * newScale;\n\n\t\ttransform = { x: newX, y: newY, scale: Math.max(0.2, newScale) };\n\t\tdebounceSaveTransform();\n\t}\n\n\tfunction zoomIn() {\n\t\ttransform.scale = Math.min(3, transform.scale * 1.2);\n\t\tdebounceSaveTransform();\n\t}\n\n\tfunction zoomOut() {\n\t\ttransform.scale = Math.max(0.2, transform.scale / 1.2);\n\t\tdebounceSaveTransform();\n\t}\n\n\tfunction handleMouseDown(e: MouseEvent) {\n\t\tif (e.button === 0 && e.target === canvasEl) {\n\t\t\tisPanning = true;\n\t\t\tstartPan = { x: e.clientX - transform.x, y: e.clientY - transform.y };\n\t\t}\n\t\tconst target = e.target as HTMLElement;\n\t\tif (!target.closest('.run-controls')) {\n\t\t\trunModeMenuOpen = null;\n\t\t}\n\t\tif (!target.closest('.sheet-selector')) {\n\t\t\tsheetDropdownOpen = false;\n\t\t}\n\t}\n\n\tfunction handleMouseMove(e: MouseEvent) {\n\t\tif (isPanning) {\n\t\t\ttransform.x = e.clientX - startPan.x;\n\t\t\ttransform.y = e.clientY - startPan.y;\n\t\t}\n\t}\n\n\tfunction handleMouseUp() {\n\t\tif (isPanning) {\n\t\t\tisPanning = false;\n\t\t\tdebounceSaveTransform();\n\t\t}\n\t}\n\n\tfunction handleWheel(e: WheelEvent) {\n\t\tconst target = e.target as HTMLElement;\n\t\tconst scrollableParent = target.closest('.item-list-items, .map-items-list, .embedded-components');\n\t\t\n\t\tif (scrollableParent && !e.ctrlKey && !e.metaKey) {\n\t\t\tconst el = scrollableParent as HTMLElement;\n\t\t\tconst canScrollUp = el.scrollTop > 0;\n\t\t\tconst canScrollDown = el.scrollTop < el.scrollHeight - el.clientHeight;\n\t\t\tconst scrollingDown = e.deltaY > 0;\n\t\t\tconst scrollingUp = e.deltaY < 0;\n\t\t\t\n\t\t\tif ((scrollingDown && canScrollDown) || (scrollingUp && canScrollUp)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\t\n\t\te.preventDefault();\n\t\t\n\t\tif (e.ctrlKey || e.metaKey) {\n\t\t\tconst rect = canvasEl.getBoundingClientRect();\n\t\t\tconst mouseX = e.clientX - rect.left;\n\t\t\tconst mouseY = e.clientY - rect.top;\n\t\t\t\n\t\t\tconst canvasX = (mouseX - transform.x) / transform.scale;\n\t\t\tconst canvasY = (mouseY - transform.y) / transform.scale;\n\t\t\t\n\t\t\tconst delta = e.deltaY > 0 ? 0.97 : 1.03;\n\t\t\tconst newScale = Math.max(0.2, Math.min(3, transform.scale * delta));\n\t\t\t\n\t\t\ttransform = {\n\t\t\t\tx: mouseX - canvasX * newScale,\n\t\t\t\ty: mouseY - canvasY * newScale,\n\t\t\t\tscale: newScale\n\t\t\t};\n\t\t} else {\n\t\t\ttransform = {\n\t\t\t\t...transform,\n\t\t\t\tx: transform.x - e.deltaX,\n\t\t\t\ty: transform.y - e.deltaY\n\t\t\t};\n\t\t}\n\t\tdebounceSaveTransform();\n\t}\n\n\tfunction handleRunNode(e: MouseEvent, nodeName: string, runMode?: 'step' | 'toHere') {\n\t\te.stopPropagation();\n\t\tconst mode = runMode ?? nodeRunModes[nodeName] ?? 'toHere';\n\t\tconst runId = `${nodeName}_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n\t\t\n\t\trunningNodes.add(nodeName);\n\t\trunningNodes = new Set(runningNodes);\n\t\tnodeRunIds[nodeName] = runId;\n\t\tdelete nodeExecutionTimes[nodeName];\n\t\t\n\t\tif (ws && wsConnected) {\n\t\t\tws.send(JSON.stringify({\n\t\t\t\taction: 'run',\n\t\t\t\tnode_name: nodeName,\n\t\t\t\tinputs: inputValues,\n\t\t\t\titem_list_values: itemListValues,\n\t\t\t\tselected_results: selectedResultIndex,\n\t\t\t\trun_id: runId,\n\t\t\t\tsheet_id: currentSheetId,\n\t\t\t\thf_token: getStoredToken(),\n\t\t\t\trun_ancestors: mode === 'toHere'\n\t\t\t}));\n\t\t}\n\t}\n\n\tfunction handleCancelNode(e: MouseEvent, nodeName: string) {\n\t\te.stopPropagation();\n\t\tconst runId = nodeRunIds[nodeName];\n\t\tif (runId && ws && wsConnected) {\n\t\t\tws.send(JSON.stringify({\n\t\t\t\taction: 'cancel',\n\t\t\t\trun_id: runId,\n\t\t\t\tnode_name: nodeName,\n\t\t\t}));\n\t\t}\n\t}\n\n\tfunction setRunMode(nodeName: string, mode: 'step' | 'toHere') {\n\t\tnodeRunModes[nodeName] = mode;\n\t\tnodeRunModes = { ...nodeRunModes };\n\t\trunModeVersion++;\n\t\trunModeMenuOpen = null;\n\t}\n\n\tfunction highlightRunTargets(nodeName: string, mode: 'step' | 'toHere') {\n\t\tif (mode === 'step') {\n\t\t\thighlightedNodes = new Set([nodeName]);\n\t\t} else {\n\t\t\tconst ancestors = getAncestors(nodeName).filter(a => !nodeResults[a]?.length);\n\t\t\thighlightedNodes = new Set([nodeName, ...ancestors]);\n\t\t}\n\t}\n\n\tfunction clearHighlight() {\n\t\thighlightedNodes = new Set();\n\t}\n\n\tfunction toggleRunModeMenu(e: MouseEvent, nodeName: string) {\n\t\te.stopPropagation();\n\t\tif (runModeMenuOpen === nodeName) {\n\t\t\trunModeMenuOpen = null;\n\t\t} else {\n\t\t\trunModeMenuOpen = nodeName;\n\t\t}\n\t}\n\n\tfunction getRunMode(nodeName: string): 'step' | 'toHere' {\n\t\tvoid runModeVersion;\n\t\treturn nodeRunModes[nodeName] ?? 'toHere';\n\t}\n\n\tfunction getBadgeStyle(type: string): string {\n\t\tconst colors: Record<string, string> = {\n\t\t\t'FN': 'var(--color-accent)',\n\t\t\t'INPUT': 'var(--secondary-500, #06b6d4)',\n\t\t\t'MAP': 'var(--primary-400, #a855f7)',\n\t\t\t'GRADIO': 'var(--color-accent)',\n\t\t\t'MODEL': 'var(--primary-500, #22c55e)',\n\t\t\t'CHOICE': 'var(--primary-400, #8b5cf6)',\n\t\t};\n\t\treturn `background: ${colors[type] || 'var(--neutral-500)'};`;\n\t}\n\n\tfunction getSelectedResults(node: GraphNode): GradioComponentData[] {\n\t\tconst results = nodeResults[node.name];\n\t\tif (!results || results.length === 0) {\n\t\t\treturn node.output_components || [];\n\t\t}\n\t\tconst idx = selectedResultIndex[node.name] ?? results.length - 1;\n\t\treturn results[idx] || node.output_components || [];\n\t}\n\n\tfunction getResultCount(nodeName: string): number {\n\t\treturn nodeResults[nodeName]?.length || 0;\n\t}\n\n\tfunction restoreInputsSnapshot(nodeName: string, index: number) {\n\t\tconst snapshots = nodeInputsSnapshots[nodeName];\n\t\tif (!snapshots || !snapshots[index]) return;\n\t\t\n\t\tconst snapshot = snapshots[index];\n\t\t\n\t\tconst inputs = snapshot.inputs || snapshot;\n\t\tfor (const [inputNodeId, nodeInputs] of Object.entries(inputs)) {\n\t\t\tif (typeof nodeInputs === 'object' && nodeInputs !== null) {\n\t\t\t\tinputValues[inputNodeId] = { ...inputValues[inputNodeId], ...nodeInputs };\n\t\t\t}\n\t\t}\n\t\t\n\t\tif (snapshot.selected_results) {\n\t\t\tfor (const [upstreamNode, resultIdx] of Object.entries(snapshot.selected_results)) {\n\t\t\t\tif (upstreamNode === nodeName) continue;\n\t\t\t\tif (typeof resultIdx === 'number') {\n\t\t\t\t\tselectedResultIndex[upstreamNode] = resultIdx;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction autoMatchDownstream(changedNode: string, newIndex: number) {\n\t\tfor (const [nodeName, snapshots] of Object.entries(nodeInputsSnapshots)) {\n\t\t\tif (!snapshots || nodeName === changedNode) continue;\n\t\t\tconst matchIdx = snapshots.findIndex(\n\t\t\t\ts => s?.selected_results?.[changedNode] === newIndex\n\t\t\t);\n\t\t\tif (matchIdx !== -1) {\n\t\t\t\tselectedResultIndex[nodeName] = matchIdx;\n\t\t\t\tautoMatchDownstream(nodeName, matchIdx);\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction prevResult(e: MouseEvent, nodeName: string) {\n\t\te.stopPropagation();\n\t\tconst current = selectedResultIndex[nodeName] ?? 0;\n\t\tif (current > 0) {\n\t\t\tconst newIndex = current - 1;\n\t\t\tselectedResultIndex[nodeName] = newIndex;\n\t\t\tselectedResultIndex = { ...selectedResultIndex };\n\t\t\trestoreInputsSnapshot(nodeName, newIndex);\n\t\t\tautoMatchDownstream(nodeName, newIndex);\n\t\t}\n\t}\n\n\tfunction nextResult(e: MouseEvent, nodeName: string) {\n\t\te.stopPropagation();\n\t\tconst total = getResultCount(nodeName);\n\t\tconst current = selectedResultIndex[nodeName] ?? 0;\n\t\tif (current < total - 1) {\n\t\t\tconst newIndex = current + 1;\n\t\t\tselectedResultIndex[nodeName] = newIndex;\n\t\t\tselectedResultIndex = { ...selectedResultIndex };\n\t\t\trestoreInputsSnapshot(nodeName, newIndex);\n\t\t\tautoMatchDownstream(nodeName, newIndex);\n\t\t}\n\t}\n\n\tfunction handleReplayItem(nodeName: string, itemIndex: number) {\n\t}\n\n\tlet zoomPercent = $derived(Math.round(transform.scale * 100));\n\n\tfunction formatTime(ms: number): string {\n\t\tif (ms < 1000) {\n\t\t\treturn `${(ms / 1000).toFixed(1)}s`;\n\t\t} else if (ms < 60000) {\n\t\t\treturn `${(ms / 1000).toFixed(1)}s`;\n\t\t} else {\n\t\t\tconst mins = Math.floor(ms / 60000);\n\t\t\tconst secs = ((ms % 60000) / 1000).toFixed(0);\n\t\t\treturn `${mins}m ${secs}s`;\n\t\t}\n\t}\n\n\tfunction getNodeTimeDisplay(nodeName: string): { text: string; isRunning: boolean; isError: boolean } | null {\n\t\tvoid timerTick;\n\t\t\n\t\tif (nodeErrors[nodeName]) {\n\t\t\treturn { text: 'Error', isRunning: false, isError: true };\n\t\t}\n\t\t\n\t\tconst isRunning = runningNodes.has(nodeName);\n\t\tconst startTime = nodeStartTimes[nodeName];\n\t\tconst finalTime = nodeExecutionTimes[nodeName];\n\t\tconst avgData = nodeAvgTimes[nodeName];\n\t\tconst avgTime = avgData ? avgData.total / avgData.count : null;\n\t\t\n\t\tif (isRunning && startTime) {\n\t\t\tconst elapsed = Date.now() - startTime;\n\t\t\tif (avgTime) {\n\t\t\t\treturn { text: `${formatTime(elapsed)}/${formatTime(avgTime)}`, isRunning: true, isError: false };\n\t\t\t}\n\t\t\treturn { text: formatTime(elapsed), isRunning: true, isError: false };\n\t\t}\n\t\t\n\t\tif (finalTime != null) {\n\t\t\treturn { text: formatTime(finalTime), isRunning: false, isError: false };\n\t\t}\n\t\t\n\t\treturn null;\n\t}\n\n\tfunction startEditingSheetName() {\n\t\tif (currentSheet) {\n\t\t\teditSheetNameValue = currentSheet.name;\n\t\t\teditingSheetName = true;\n\t\t}\n\t}\n\n\tfunction finishEditingSheetName() {\n\t\tif (editingSheetName && currentSheetId && editSheetNameValue.trim()) {\n\t\t\trenameSheet(currentSheetId, editSheetNameValue.trim());\n\t\t}\n\t\teditingSheetName = false;\n\t}\n\n\tfunction handleSheetNameKeydown(e: KeyboardEvent) {\n\t\tif (e.key === 'Enter') {\n\t\t\tfinishEditingSheetName();\n\t\t} else if (e.key === 'Escape') {\n\t\t\teditingSheetName = false;\n\t\t}\n\t}\n\n\tfunction resetSheetValues() {\n\t\tif (confirm('Are you sure you want to reset all component values? This cannot be undone.')) {\n\t\t\tinputValues = {};\n\t\t\tnodeResults = {};\n\t\t\tselectedResultIndex = {};\n\t\t\titemListValues = {};\n\t\t\tselectedVariants = {};\n\t\t\tnodeErrors = {};\n\t\t\tnodeExecutionTimes = {};\n\t\t\tnodeInputsSnapshots = {};\n\t\t\tnodeAvgTimes = {};\n\t\t\tnodeStartTimes = {};\n\t\t\tif (graphData?.nodes) {\n\t\t\t\tfor (const node of graphData.nodes) {\n\t\t\t\t\tif (node.output_components) {\n\t\t\t\t\t\tfor (const comp of node.output_components) {\n\t\t\t\t\t\t\tcomp.value = null;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tgraphData = { ...graphData };\n\t\t\t}\n\t\t\tif (ws && wsConnected && canPersist && currentSheetId) {\n\t\t\t\tws.send(JSON.stringify({ action: 'clear_sheet' }));\n\t\t\t}\n\t\t}\n\t}\n</script>\n\n<div \n\tclass=\"canvas\"\n\tclass:dark={isDark}\n\tbind:this={canvasEl}\n\tonmousedown={handleMouseDown}\n\tonmousemove={handleMouseMove}\n\tonmouseup={handleMouseUp}\n\tonmouseleave={handleMouseUp}\n\tonwheel={handleWheel}\n\trole=\"application\"\n>\n\t<div class=\"grid-bg\"></div>\n\n\t<div \n\t\tclass=\"canvas-transform\"\n\t\tstyle=\"transform: translate({transform.x}px, {transform.y}px) scale({transform.scale})\"\n\t>\n\t\t<svg class=\"edges-svg\">\n\t\t\t{#each edgePaths as edge (edge.id)}\n\t\t\t\t<path d={edge.d} class=\"edge-path\" class:stale={edge.isStale} class:will-run={highlightedNodes.has(edge.fromNodeName) && highlightedNodes.has(edge.toNodeName)} />\n\t\t\t\t{#if edge.forkPaths}\n\t\t\t\t\t{#each edge.forkPaths as forkD}\n\t\t\t\t\t\t<path d={forkD} class=\"edge-path edge-fork\" class:stale={edge.isStale} class:will-run={highlightedNodes.has(edge.fromNodeName) && highlightedNodes.has(edge.toNodeName)} />\n\t\t\t\t\t{/each}\n\t\t\t\t{/if}\n\t\t\t{/each}\n\t\t</svg>\n\n\t\t{#each nodes as node (node.id)}\n\t\t\t{@const componentsToRender = getComponentsToRender(node)}\n\t\t\t{@const timeDisplay = getNodeTimeDisplay(node.name)}\n\t\t\t<div \n\t\t\t\tclass=\"node\"\n\t\t\t\tclass:will-run={highlightedNodes.has(node.name)}\n\t\t\t\tstyle=\"left: {node.x}px; top: {node.y}px; width: {NODE_WIDTH}px;\"\n\t\t\t>\n\t\t\t\t{#if timeDisplay}\n\t\t\t\t\t<div class=\"exec-time\" class:running={timeDisplay.isRunning} class:error={timeDisplay.isError}>{timeDisplay.text}</div>\n\t\t\t\t{/if}\n\t\t\t\t<div class=\"node-header\">\n\t\t\t\t\t<span class=\"type-badge\" style={getBadgeStyle(node.type)}>{node.type}{#if node.is_local}&nbsp;⚡{/if}</span>\n\t\t\t\t\t{#if node.url}\n\t\t\t\t\t\t<a class=\"node-name node-link\" href={node.url} target=\"_blank\" rel=\"noopener noreferrer\" title=\"Open on Hugging Face\">{node.name}</a>\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<span class=\"node-name\">{node.name}</span>\n\t\t\t\t\t{/if}\n\t\t\t\t\t{#if !node.is_input_node}\n\t\t\t\t\t\t{#key runModeVersion}\n\t\t\t\t\t\t<div class=\"run-controls\">\n\t\t\t\t\t\t\t{#if runningNodes.has(node.name)}\n\t\t\t\t\t\t\t<span \n\t\t\t\t\t\t\t\tclass=\"run-btn running\"\n\t\t\t\t\t\t\t\tonclick={(e) => handleCancelNode(e, node.name)}\n\t\t\t\t\t\t\t\ttitle=\"Stop\"\n\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<svg class=\"run-icon-svg\" viewBox=\"0 0 12 12\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t\t<rect x=\"2\" y=\"2\" width=\"8\" height=\"8\" rx=\"1\"/>\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<span \n\t\t\t\t\t\t\t\tclass=\"run-btn\"\n\t\t\t\t\t\t\t\tonclick={(e) => handleRunNode(e, node.name)}\n\t\t\t\t\t\t\t\tonmouseenter={() => highlightRunTargets(node.name, getRunMode(node.name))}\n\t\t\t\t\t\t\t\tonmouseleave={() => clearHighlight()}\n\t\t\t\t\t\t\t\ttitle={(nodeRunModes[node.name] ?? 'toHere') === 'toHere' ? \"Run to here\" : \"Run this step\"}\n\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#if node.is_map_node || (nodeRunModes[node.name] ?? 'toHere') === 'toHere'}\n\t\t\t\t\t\t\t\t\t<svg class=\"run-icon-svg run-icon-double\" viewBox=\"0 0 14 12\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t\t\t<path d=\"M2 1 L10 6 L2 11 Z\" opacity=\"0.5\" transform=\"translate(-2, 0)\"/>\n\t\t\t\t\t\t\t\t\t\t<path d=\"M2 1 L10 6 L2 11 Z\" transform=\"translate(2, 0)\"/>\n\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\t<svg class=\"run-icon-svg\" viewBox=\"0 0 14 12\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t\t\t<path d=\"M3 1 L11 6 L3 11 Z\"/>\n\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t<span \n\t\t\t\t\t\t\tclass=\"run-mode-toggle\"\n\t\t\t\t\t\t\t\tonclick={(e) => toggleRunModeMenu(e, node.name)}\n\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t\t\ttitle=\"Run options\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<svg viewBox=\"0 0 10 6\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t\t<path d=\"M1 1 L5 5 L9 1\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/>\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{#if runModeMenuOpen === node.name}\n\t\t\t\t\t\t\t\t<div class=\"run-mode-menu\" onmouseleave={() => clearHighlight()}>\n\t\t\t\t\t\t\t\t\t<button \n\t\t\t\t\t\t\t\t\t\tclass=\"run-mode-option\"\n\t\t\t\t\t\t\t\t\t\tclass:active={(nodeRunModes[node.name] ?? 'toHere') === 'step'}\n\t\t\t\t\t\t\t\t\t\tonclick={(e) => { e.stopPropagation(); setRunMode(node.name, 'step'); clearHighlight(); }}\n\t\t\t\t\t\t\t\t\t\tonmouseenter={() => highlightRunTargets(node.name, 'step')}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<svg class=\"run-mode-icon\" viewBox=\"0 0 10 12\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M1 1 L9 6 L1 11 Z\"/>\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t\t<span>Run this step</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t<button \n\t\t\t\t\t\t\t\t\t\tclass=\"run-mode-option\"\n\t\t\t\t\t\t\t\t\t\tclass:active={(nodeRunModes[node.name] ?? 'toHere') === 'toHere'}\n\t\t\t\t\t\t\t\t\t\tonclick={(e) => { e.stopPropagation(); setRunMode(node.name, 'toHere'); clearHighlight(); }}\n\t\t\t\t\t\t\t\t\t\tonmouseenter={() => highlightRunTargets(node.name, 'toHere')}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<svg class=\"run-mode-icon run-mode-icon-double\" viewBox=\"0 0 14 12\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M2 1 L10 6 L2 11 Z\" opacity=\"0.5\" transform=\"translate(-2, 0)\"/>\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M2 1 L10 6 L2 11 Z\" transform=\"translate(2, 0)\"/>\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t\t<span>Run to here</span>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/key}\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"node-body\">\n\t\t\t\t\t<div class=\"ports-left\">\n\t\t\t\t\t\t{#each node.inputs as port (port.name)}\n\t\t\t\t\t\t\t<div class=\"port-row\">\n\t\t\t\t\t\t\t\t<span class=\"port-dot input\"></span>\n\t\t\t\t\t\t\t\t<span class=\"port-label\">{port.name}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"ports-right\">\n\t\t\t\t\t\t{#each node.outputs as portName (portName)}\n\t\t\t\t\t\t\t<div class=\"port-row\">\n\t\t\t\t\t\t\t\t<span class=\"port-label\">{portName}</span>\n\t\t\t\t\t\t\t\t<span class=\"port-dot output\"></span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{#if nodeErrors[node.name]}\n\t\t\t\t\t<div class=\"node-error\">\n\t\t\t\t\t\t<div class=\"node-error-label\">Error</div>\n\t\t\t\t\t\t<div class=\"node-error-message\">{nodeErrors[node.name]}</div>\n\t\t\t\t\t</div>\n\t\t\t\t{:else if node.variants && node.variants.length > 0}\n\t\t\t\t\t{@const currentVariantIdx = getSelectedVariant(node)}\n\t\t\t\t\t<div class=\"variants-accordion\">\n\t\t\t\t\t\t{#each node.variants as variant, idx (idx)}\n\t\t\t\t\t\t\t{@const isSelected = idx === currentVariantIdx}\n\t\t\t\t\t\t\t<div \n\t\t\t\t\t\t\t\tclass=\"variant-card\"\n\t\t\t\t\t\t\t\tclass:selected={isSelected}\n\t\t\t\t\t\t\t\tonclick={() => handleVariantSelect(node.id, idx)}\n\t\t\t\t\t\t\t\trole=\"button\"\n\t\t\t\t\t\t\t\ttabindex=\"0\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div class=\"variant-header\">\n\t\t\t\t\t\t\t\t\t<span class=\"variant-radio\" class:checked={isSelected}>\n\t\t\t\t\t\t\t\t\t\t{#if isSelected}●{:else}○{/if}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span class=\"variant-name\">{variant.name}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{#if isSelected && variant.input_components.length > 0}\n\t\t\t\t\t\t\t\t\t<div class=\"variant-inputs\">\n\t\t\t\t\t\t\t\t\t\t{#each variant.input_components as comp (comp.port_name)}\n\t\t\t\t\t\t\t\t\t\t\t<EmbeddedComponent\n\t\t\t\t\t\t\t\t\t\t\t\t{comp}\n\t\t\t\t\t\t\t\t\t\t\t\tnodeId={node.id}\n\t\t\t\t\t\t\t\t\t\t\t\tisInputNode={true}\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={inputValues[node.id]?.[`variant_${idx}_${comp.port_name}`] ?? comp.value ?? ''}\n\t\t\t\t\t\t\t\t\t\t\t\tonchange={(portName, value) => handleInputChange(node.id, `variant_${idx}_${portName}`, value)}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</div>\n\t\t\t\t{:else if componentsToRender.length > 0}\n\t\t\t\t\t<div class=\"embedded-components\">\n\t\t\t\t\t\t{#each componentsToRender as comp (comp.port_name)}\n\t\t\t\t\t\t\t<EmbeddedComponent\n\t\t\t\t\t\t\t\t{comp}\n\t\t\t\t\t\t\t\tnodeId={node.id}\n\t\t\t\t\t\t\t\tisInputNode={node.is_input_node}\n\t\t\t\t\t\t\t\tvalue={getComponentValue(node, comp)}\n\t\t\t\t\t\t\t\tonchange={(portName, value) => handleInputChange(node.id, portName, value)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</div>\n\t\t\t\t\t\n\t\t\t\t\t{#if !node.is_input_node && getResultCount(node.name) > 1}\n\t\t\t\t\t\t<div class=\"result-selector\">\n\t\t\t\t\t\t\t<button \n\t\t\t\t\t\t\t\tclass=\"result-nav\" \n\t\t\t\t\t\t\t\tonclick={(e) => prevResult(e, node.name)}\n\t\t\t\t\t\t\t\tdisabled={(selectedResultIndex[node.name] ?? 0) === 0}\n\t\t\t\t\t\t\t>‹</button>\n\t\t\t\t\t\t\t<span class=\"result-counter\">\n\t\t\t\t\t\t\t\t{(selectedResultIndex[node.name] ?? 0) + 1}/{getResultCount(node.name)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<button \n\t\t\t\t\t\t\t\tclass=\"result-nav\" \n\t\t\t\t\t\t\t\tonclick={(e) => nextResult(e, node.name)}\n\t\t\t\t\t\t\t\tdisabled={(selectedResultIndex[node.name] ?? 0) >= getResultCount(node.name) - 1}\n\t\t\t\t\t\t\t>›</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t{/if}\n\n\t\t\t\t{#if node.is_map_node && node.map_items && node.map_items.length > 0}\n\t\t\t\t\t<MapItemsSection\n\t\t\t\t\t\tnodeId={node.id}\n\t\t\t\t\t\tnodeName={node.name}\n\t\t\t\t\t\titems={node.map_items}\n\t\t\t\t\t\tonReplayItem={handleReplayItem}\n\t\t\t\t\t/>\n\t\t\t\t{/if}\n\n\t\t\t\t{#if node.item_list_schema && node.item_list_items && node.item_list_items.length > 0}\n\t\t\t\t\t<ItemListSection\n\t\t\t\t\t\tnodeId={node.id}\n\t\t\t\t\t\tschema={node.item_list_schema}\n\t\t\t\t\t\titems={node.item_list_items}\n\t\t\t\t\t\tgetValue={getItemListValue}\n\t\t\t\t\t\tonchange={handleItemListChange}\n\t\t\t\t\t/>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/each}\n\t</div>\n\n\t<div class=\"zoom-controls\">\n\t\t<img src={isDark ? \"/daggr-assets/logo_dark_small.png\" : \"/daggr-assets/logo_light_small.png\"} alt=\"daggr\" class=\"daggr-logo\" />\n\t\t<button class=\"zoom-btn\" onclick={zoomOut} title=\"Zoom out\">−</button>\n\t\t<span class=\"zoom-level\">{zoomPercent}%</span>\n\t\t<button class=\"zoom-btn\" onclick={zoomIn} title=\"Zoom in\">+</button>\n\t\t<button class=\"zoom-btn fit-btn\" onclick={zoomToFit} title=\"Fit all nodes\">\n\t\t\t<svg viewBox=\"0 0 16 16\" fill=\"currentColor\" class=\"fit-icon\">\n\t\t\t\t<path d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2zM12 0v1.5h2a.5.5 0 0 1 .5.5v2H16V2a2 2 0 0 0-2-2h-2zM0 12v2a2 2 0 0 0 2 2h2v-1.5H2a.5.5 0 0 1-.5-.5v-2H0zM14.5 12v2a.5.5 0 0 1-.5.5h-2V16h2a2 2 0 0 0 2-2v-2h-1.5z\"/>\n\t\t\t\t<rect x=\"4.5\" y=\"4.5\" width=\"7\" height=\"7\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/>\n\t\t\t</svg>\n\t\t</button>\n\t</div>\n\n\t<div class=\"title-bar\">\n\t\t<span class=\"title\">{graphData?.name || 'daggr'}</span>\n\t\t{#if canPersist && sheets.length > 0}\n\t\t\t<span class=\"title-separator\">|</span>\n\t\t\t<div class=\"sheet-selector\">\n\t\t\t\t{#if editingSheetName}\n\t\t\t\t\t<input\n\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\tclass=\"sheet-name-input\"\n\t\t\t\t\t\tbind:value={editSheetNameValue}\n\t\t\t\t\t\tonblur={finishEditingSheetName}\n\t\t\t\t\t\tonkeydown={handleSheetNameKeydown}\n\t\t\t\t\t\tautofocus\n\t\t\t\t\t/>\n\t\t\t\t{:else}\n\t\t\t\t\t<button \n\t\t\t\t\t\tclass=\"sheet-current\"\n\t\t\t\t\t\tonclick={() => sheetDropdownOpen = !sheetDropdownOpen}\n\t\t\t\t\t>\n\t\t\t\t\t\t<span class=\"sheet-name\">{currentSheet?.name || 'Sheet'}</span>\n\t\t\t\t\t\t<svg class=\"dropdown-arrow\" viewBox=\"0 0 10 6\" fill=\"currentColor\">\n\t\t\t\t\t\t\t<path d=\"M1 1 L5 5 L9 1\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t\t<button \n\t\t\t\t\t\tclass=\"sheet-action-btn\"\n\t\t\t\t\t\tonclick={startEditingSheetName}\n\t\t\t\t\t\ttitle=\"Rename sheet\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg viewBox=\"0 0 16 16\" fill=\"currentColor\">\n\t\t\t\t\t\t\t<path d=\"M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t\t<button \n\t\t\t\t\t\tclass=\"sheet-action-btn sheet-reset-btn\"\n\t\t\t\t\t\tonclick={resetSheetValues}\n\t\t\t\t\t\ttitle=\"Reset all values\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg viewBox=\"0 0 16 16\" fill=\"currentColor\">\n\t\t\t\t\t\t\t<path d=\"M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z\"/>\n\t\t\t\t\t\t\t<path fill-rule=\"evenodd\" d=\"M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t{/if}\n\t\t\t\t{#if sheetDropdownOpen}\n\t\t\t\t\t<div class=\"sheet-dropdown\">\n\t\t\t\t\t\t{#each sheets as sheet (sheet.sheet_id)}\n\t\t\t\t\t\t\t<div \n\t\t\t\t\t\t\t\tclass=\"sheet-option\"\n\t\t\t\t\t\t\t\tclass:active={sheet.sheet_id === currentSheetId}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<button \n\t\t\t\t\t\t\t\t\tclass=\"sheet-option-name\"\n\t\t\t\t\t\t\t\t\tonclick={() => selectSheet(sheet.sheet_id)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{sheet.name}\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t{#if sheets.length > 1}\n\t\t\t\t\t\t\t\t\t<button \n\t\t\t\t\t\t\t\t\t\tclass=\"sheet-delete\"\n\t\t\t\t\t\t\t\t\t\tonclick={() => deleteSheet(sheet.sheet_id)}\n\t\t\t\t\t\t\t\t\t\ttitle=\"Delete sheet\"\n\t\t\t\t\t\t\t\t\t>×</button>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t<button class=\"sheet-new\" onclick={() => createSheet()}>\n\t\t\t\t\t\t\t+ New Sheet\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n\t<div class=\"user-controls-wrapper\">\n\t\t{#if !wsConnected}\n\t\t\t<div class=\"status-pill info\">Connecting...</div>\n\t\t{:else if !graphData}\n\t\t\t<div class=\"status-pill info\">Loading graph...</div>\n\t\t{/if}\n\t\t{#if hfUser && wsConnected && graphData}\n\t\t\t<div class=\"hf-user\">\n\t\t\t\t{#if hfUser.avatar_url}\n\t\t\t\t\t<img src={hfUser.avatar_url} alt=\"\" class=\"hf-avatar\" />\n\t\t\t\t{/if}\n\t\t\t\t<span class=\"hf-username\">{hfUser.username}</span>\n\t\t\t<button\n\t\t\t\tclass=\"logout-btn\"\n\t\t\t\tonclick={(e) => {\n\t\t\t\t\te.stopPropagation();\n\t\t\t\t\thandleLogout();\n\t\t\t\t}}\n\t\t\t\ttitle=\"Logout\"\n\t\t\t>×</button>\n\t\t\t<div class=\"hf-tooltip\">\n\t\t\t\tYour Hugging Face token is used for all GradioNode and InferenceNode calls. This enables ZeroGPU quota tracking and access to private Spaces and gated models.\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{:else if wsConnected && graphData}\n\t\t\t<div class=\"login-section\">\n\t\t\t\t<button class=\"login-btn\" onclick={() => showLoginTooltip = !showLoginTooltip}>\n\t\t\t\t\t<img src=\"/daggr-assets/hf-logo-pirate.png\" alt=\"HF\" class=\"hf-logo-icon\" />\n\t\t\t\t\t<span>Login</span>\n\t\t\t\t</button>\n\t\t\t\t{#if showLoginTooltip}\n\t\t\t\t\t<div class=\"login-tooltip\">\n\t\t\t\t\t\t<div class=\"login-tooltip-header\">Login with Hugging Face</div>\n\t\t\t\t\t\t{#if isOnSpaces}\n\t\t\t\t\t\t\t<p class=\"login-tooltip-desc login-tooltip-highlight\">\n\t\t\t\t\t\t\t\tLogin to save your outputs and resume your work later.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t<p class=\"login-tooltip-desc\">\n\t\t\t\t\t\t\tYour token is used to authenticate with Hugging Face APIs for InferenceNode calls and ZeroGPU-powered Spaces. Create a token with <strong>Read</strong> scope (or <strong>Fine-grained</strong> with Inference API access) at <a href=\"https://huggingface.co/settings/tokens\" target=\"_blank\" rel=\"noopener\">huggingface.co/settings/tokens</a>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype=\"password\"\n\t\t\t\t\t\t\tclass=\"login-token-input\"\n\t\t\t\t\t\t\tplaceholder=\"hf_...\"\n\t\t\t\t\t\t\tbind:value={tokenInputValue}\n\t\t\t\t\t\t\tonkeydown={(e) => e.key === 'Enter' && handleLogin()}\n\t\t\t\t\t\t\tdisabled={loginLoading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{#if loginError}\n\t\t\t\t\t\t\t<div class=\"login-error\">{loginError}</div>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t<button class=\"login-submit-btn\" onclick={handleLogin} disabled={loginLoading}>\n\t\t\t\t\t\t\t{loginLoading ? 'Verifying...' : 'Login'}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t{/if}\n\t\t<button \n\t\t\tclass=\"theme-btn\" \n\t\t\tonclick={toggleTheme} \n\t\t\ttitle={isDark ? \"Switch to light mode\" : \"Switch to dark mode\"}\n\t\t>\n\t\t{#if isDark}\n\t\t\t<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/></svg>\n\t\t{:else}\n\t\t\t<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>\n\t\t\t{/if}\n\t\t</button>\n\t</div>\n</div>\n\n<style>\n\t.canvas {\n\t\tposition: fixed;\n\t\tinset: 0;\n\t\twidth: 100vw;\n\t\theight: 100vh;\n\t\toverflow: hidden;\n\t\tbackground: var(--body-background-fill);\n\t\tcursor: grab;\n\t\tfont-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n\t}\n\n\t.canvas:active {\n\t\tcursor: grabbing;\n\t}\n\n\t.grid-bg {\n\t\tposition: absolute;\n\t\tinset: 0;\n\t\tbackground-image: radial-gradient(circle, color-mix(in srgb, var(--color-accent) 6%, transparent) 1px, transparent 1px);\n\t\tbackground-size: 20px 20px;\n\t\tpointer-events: none;\n\t}\n\n\t.canvas-transform {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\ttransform-origin: 0 0;\n\t}\n\n\t.connection-status {\n\t\tposition: fixed;\n\t\ttop: 16px;\n\t\tright: 16px;\n\t\tbackground: color-mix(in srgb, var(--color-accent) 90%, transparent);\n\t\tcolor: var(--button-primary-text-color);\n\t\tpadding: 8px 16px;\n\t\tborder-radius: 8px;\n\t\tfont-size: 12px;\n\t\tfont-weight: 600;\n\t\tz-index: 1000;\n\t}\n\n\t.title-bar {\n\t\tposition: fixed;\n\t\ttop: 16px;\n\t\tleft: 50%;\n\t\ttransform: translateX(-50%);\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 90%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 8px;\n\t\tpadding: 8px 20px;\n\t\tz-index: 100;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 12px;\n\t}\n\n\t.title {\n\t\tfont-size: 14px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.title-separator {\n\t\tcolor: color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t\tfont-weight: 300;\n\t}\n\n\t.sheet-selector {\n\t\tposition: relative;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 2px;\n\t}\n\n\t.sheet-current {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 6px;\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-size: 13px;\n\t\tfont-weight: 500;\n\t\tcursor: pointer;\n\t\tpadding: 4px 8px;\n\t\tborder-radius: 4px;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.sheet-current:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.sheet-name {\n\t\tmax-width: 150px;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t}\n\n\t.sheet-name-input {\n\t\tbackground: color-mix(in srgb, var(--body-background-fill) 30%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);\n\t\tborder-radius: 4px;\n\t\tcolor: var(--body-text-color);\n\t\tfont-size: 13px;\n\t\tfont-weight: 500;\n\t\tpadding: 4px 8px;\n\t\twidth: 140px;\n\t\toutline: none;\n\t}\n\n\t.sheet-name-input:focus {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.dropdown-arrow {\n\t\twidth: 10px;\n\t\theight: 6px;\n\t\topacity: 0.6;\n\t}\n\n\t.sheet-action-btn {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tcursor: pointer;\n\t\tpadding: 4px;\n\t\tborder-radius: 4px;\n\t\ttransition: all 0.15s;\n\t\topacity: 0.6;\n\t}\n\n\t.sheet-action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tcolor: var(--color-accent);\n\t\topacity: 1;\n\t}\n\n\t.sheet-action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t}\n\n\t.sheet-reset-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--error-text-color) 15%, transparent);\n\t\tcolor: var(--error-text-color);\n\t}\n\n\t.sheet-dropdown {\n\t\tposition: absolute;\n\t\ttop: 100%;\n\t\tleft: 0;\n\t\tmargin-top: 8px;\n\t\tmin-width: 180px;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 98%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t\tborder-radius: 8px;\n\t\tpadding: 6px;\n\t\tbox-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);\n\t}\n\n\t.sheet-option {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tborder-radius: 4px;\n\t\toverflow: hidden;\n\t}\n\n\t.sheet-option.active {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t}\n\n\t.sheet-option-name {\n\t\tflex: 1;\n\t\tbackground: none;\n\t\tborder: none;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-size: 12px;\n\t\tpadding: 8px 10px;\n\t\ttext-align: left;\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.sheet-option-name:hover {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.sheet-option.active .sheet-option-name {\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.sheet-delete {\n\t\tbackground: none;\n\t\tborder: none;\n\t\tcolor: var(--neutral-500);\n\t\tfont-size: 16px;\n\t\tpadding: 6px 10px;\n\t\tcursor: pointer;\n\t\ttransition: color 0.15s;\n\t}\n\n\t.sheet-delete:hover {\n\t\tcolor: var(--error-text-color);\n\t}\n\n\t.sheet-new {\n\t\twidth: 100%;\n\t\tbackground: none;\n\t\tborder: none;\n\t\tborder-top: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-size: 12px;\n\t\tpadding: 10px;\n\t\tmargin-top: 4px;\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.sheet-new:hover {\n\t\tcolor: var(--color-accent);\n\t\tbackground: color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t}\n\n\t.hf-user {\n\t\tposition: relative;\n\t\tcursor: help;\n\t}\n\n\t.hf-avatar {\n\t\twidth: 22px;\n\t\theight: 22px;\n\t\tborder-radius: 50%;\n\t\tobject-fit: cover;\n\t}\n\n\t.hf-username {\n\t\tfont-size: 13px;\n\t\tfont-weight: 500;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.hf-tooltip {\n\t\tposition: absolute;\n\t\ttop: 100%;\n\t\tright: 0;\n\t\tmargin-top: 8px;\n\t\twidth: 280px;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 98%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t\tborder-radius: 8px;\n\t\tpadding: 12px;\n\t\tfont-size: 12px;\n\t\tline-height: 1.5;\n\t\tcolor: var(--body-text-color-subdued);\n\t\twhite-space: normal;\n\t\ttext-overflow: clip;\n\t\toverflow: visible;\n\t\ttext-align: left;\n\t\topacity: 0;\n\t\tvisibility: hidden;\n\t\ttransition: opacity 0.2s, visibility 0.2s;\n\t\tpointer-events: none;\n\t\tbox-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);\n\t\tz-index: 1200;\n\t}\n\n\t.hf-user:hover .hf-tooltip {\n\t\topacity: 1;\n\t\tvisibility: visible;\n\t}\n\n\t.hf-user:hover .logout-btn {\n\t\topacity: 1;\n\t}\n\n\t.logout-btn:hover {\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.login-section {\n\t\tposition: relative;\n\t}\n\n\t.login-btn {\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 90%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 8px;\n\t\tpadding: 8px 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tfont-size: 12px;\n\t\ttransition: all 0.2s;\n\t}\n\n\t.logout-btn {\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tcolor: var(--neutral-500);\n\t\tfont-size: 18px;\n\t\tcursor: pointer;\n\t\tmargin-left: 4px;\n\t\topacity: 0;\n\t\ttransition: opacity 0.2s;\n\t\tposition: relative;\n\t\tz-index: 10;\n\t}\n\n\t.login-btn:hover {\n\t\tborder-color: color-mix(in srgb, var(--color-accent) 40%, transparent);\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.hf-logo-icon {\n\t\twidth: 18px;\n\t\theight: 18px;\n\t\tobject-fit: contain;\n\t}\n\n\t.login-tooltip {\n\t\tposition: absolute;\n\t\ttop: calc(100% + 8px);\n\t\tright: 0;\n\t\tz-index: 1200;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 98%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t\tborder-radius: 10px;\n\t\tpadding: 16px;\n\t\twidth: 280px;\n\t\tbox-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);\n\t}\n\n\t.login-tooltip-header {\n\t\tfont-size: 14px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--color-accent);\n\t\tmargin-bottom: 8px;\n\t}\n\n\t.login-tooltip-desc {\n\t\tfont-size: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tmargin: 0 0 12px 0;\n\t\tline-height: 1.4;\n\t}\n\n\t.login-tooltip-desc a {\n\t\tcolor: var(--color-accent);\n\t\ttext-decoration: none;\n\t}\n\n\t.login-tooltip-desc a:hover {\n\t\ttext-decoration: underline;\n\t}\n\n\t.login-tooltip-highlight {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t\tborder-radius: 6px;\n\t\tpadding: 8px 10px;\n\t\tcolor: var(--color-accent);\n\t\tfont-weight: 500;\n\t}\n\n\t.login-token-input {\n\t\twidth: 100%;\n\t\tpadding: 10px 12px;\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 6px;\n\t\tcolor: var(--body-text-color);\n\t\tfont-size: 13px;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tmargin-bottom: 8px;\n\t\tbox-sizing: border-box;\n\t}\n\n\t.login-token-input:focus {\n\t\toutline: none;\n\t\tborder-color: color-mix(in srgb, var(--color-accent) 50%, transparent);\n\t}\n\n\t.login-token-input::placeholder {\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.login-error {\n\t\tfont-size: 11px;\n\t\tcolor: var(--error-text-color);\n\t\tmargin-bottom: 8px;\n\t}\n\n\t.login-submit-btn {\n\t\twidth: 100%;\n\t\tpadding: 10px;\n\t\tbackground: var(--color-accent);\n\t\tborder: none;\n\t\tborder-radius: 6px;\n\t\tcolor: var(--button-primary-text-color);\n\t\tfont-size: 13px;\n\t\tfont-weight: 600;\n\t\tcursor: pointer;\n\t\ttransition: background 0.2s;\n\t}\n\n\t.login-submit-btn:hover:not(:disabled) {\n\t\tbackground: var(--color-accent-soft);\n\t}\n\n\t.login-submit-btn:disabled {\n\t\topacity: 0.6;\n\t\tcursor: not-allowed;\n\t}\n\n\t.zoom-controls {\n\t\tposition: fixed;\n\t\tbottom: 16px;\n\t\tleft: 16px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 4px;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 90%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 8px;\n\t\tpadding: 4px;\n\t\tz-index: 100;\n\t}\n\n\t.daggr-logo {\n\t\theight: 20px;\n\t\twidth: auto;\n\t\tmargin: 0 6px 0 4px;\n\t\topacity: 0.9;\n\t}\n\n\t.zoom-btn {\n\t\twidth: 28px;\n\t\theight: 28px;\n\t\tborder: none;\n\t\tbackground: transparent;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-size: 16px;\n\t\tfont-weight: 600;\n\t\tcursor: pointer;\n\t\tborder-radius: 4px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.zoom-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.fit-btn {\n\t\tmargin-left: 4px;\n\t\tborder-left: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tpadding-left: 8px;\n\t\tborder-radius: 0 4px 4px 0;\n\t}\n\n\t.fit-icon {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t}\n\n\t.zoom-level {\n\t\tfont-size: 11px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tmin-width: 40px;\n\t\ttext-align: center;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t}\n\n\t.edges-svg {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\twidth: 4000px;\n\t\theight: 3000px;\n\t\tpointer-events: none;\n\t\toverflow: visible;\n\t}\n\n\t.edge-path {\n\t\tfill: none;\n\t\tstroke: var(--color-accent);\n\t\ttransition: stroke 0.15s ease, stroke-width 0.15s ease, filter 0.15s ease;\n\t\tstroke-width: 2.5;\n\t\tstroke-linecap: round;\n\t\ttransition: stroke 0.2s ease;\n\t}\n\n\t.edge-path.stale {\n\t\tstroke: var(--neutral-500);\n\t}\n\n\t.edge-path.will-run {\n\t\tstroke: var(--color-accent);\n\t\tstroke-width: 3;\n\t\tfilter: drop-shadow(0 0 4px var(--color-accent));\n\t}\n\n\t.edge-fork {\n\t\tstroke-width: 2;\n\t}\n\n\t.node {\n\t\tposition: absolute;\n\t\tbackground: linear-gradient(175deg, color-mix(in srgb, var(--block-background-fill) 92%, transparent) 0%, color-mix(in srgb, var(--block-background-fill) 92%, black 8%) 100%);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 10px;\n\t\tbox-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);\n\t\toverflow: visible;\n\t\tcursor: default;\n\t\ttransition: border-color 0.15s ease, box-shadow 0.15s ease;\n\t}\n\n\t.node.will-run {\n\t\tborder-color: var(--color-accent);\n\t\tbox-shadow: 0 0 20px color-mix(in srgb, var(--color-accent) 50%, transparent), 0 4px 20px rgba(0, 0, 0, 0.5);\n\t}\n\n\t.exec-time {\n\t\tposition: absolute;\n\t\ttop: -18px;\n\t\tright: 4px;\n\t\tfont-size: 10px;\n\t\tfont-weight: 500;\n\t\tcolor: var(--neutral-500);\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t}\n\n\t.exec-time.running {\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.exec-time.error {\n\t\tcolor: var(--error-text-color);\n\t\tfont-weight: 600;\n\t}\n\n\t.node-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 0 12px;\n\t\theight: 36px;\n\t\tbackground: color-mix(in srgb, var(--color-accent) 6%, transparent);\n\t\tborder-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t}\n\n\t.type-badge {\n\t\tfont-size: 8px;\n\t\tfont-weight: 700;\n\t\ttext-transform: uppercase;\n\t\tletter-spacing: 0.5px;\n\t\tpadding: 3px 8px;\n\t\tborder-radius: 4px;\n\t\tcolor: var(--button-primary-text-color);\n\t\tflex-shrink: 0;\n\t}\n\n\t.node-name {\n\t\tflex: 1;\n\t\tfont-size: 11px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--body-text-color);\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t}\n\n\t.node-link {\n\t\ttext-decoration: none;\n\t\ttransition: color 0.15s;\n\t}\n\n\t.node-link:hover {\n\t\tcolor: var(--color-accent);\n\t\ttext-decoration: underline;\n\t}\n\n\t.run-controls {\n\t\tposition: relative;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t}\n\n\t.run-btn {\n\t\tposition: relative;\n\t\tfont-size: 10px;\n\t\tcolor: var(--color-accent);\n\t\tcursor: pointer;\n\t\tpadding: 2px 6px;\n\t\tborder-radius: 4px 0 0 4px;\n\t\tborder: 1px solid var(--color-accent);\n\t\tborder-right: none;\n\t\tbackground: transparent;\n\t\tuser-select: none;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.run-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t}\n\n\t.run-btn.running {\n\t\tanimation: pulse 1.5s ease-in-out infinite;\n\t}\n\n\t.run-mode-toggle {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\twidth: 16px;\n\t\theight: 18px;\n\t\tborder: 1px solid var(--color-accent);\n\t\tborder-radius: 0 4px 4px 0;\n\t\tcolor: var(--color-accent);\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.run-mode-toggle:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t}\n\n\t.run-mode-toggle svg {\n\t\twidth: 8px;\n\t\theight: 5px;\n\t}\n\n\t.run-mode-menu {\n\t\tposition: absolute;\n\t\ttop: calc(100% + 4px);\n\t\tright: 0;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 98%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t\tborder-radius: 6px;\n\t\tpadding: 4px;\n\t\tmin-width: 130px;\n\t\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);\n\t\tz-index: 1000;\n\t}\n\n\t.run-mode-option {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\twidth: 100%;\n\t\tpadding: 6px 8px;\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tborder-radius: 4px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-size: 11px;\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t\ttext-align: left;\n\t}\n\n\t.run-mode-option:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.run-mode-option.active {\n\t\tcolor: var(--color-accent);\n\t\tbackground: color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t}\n\n\t.run-mode-icon {\n\t\twidth: 10px;\n\t\theight: 10px;\n\t\tflex-shrink: 0;\n\t}\n\n\t.run-mode-icon-double {\n\t\twidth: 12px;\n\t}\n\n\t.run-icon-svg {\n\t\twidth: 14px;\n\t\theight: 12px;\n\t\tdisplay: block;\n\t}\n\n\t@keyframes pulse {\n\t\t0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 40%, transparent); }\n\t\t50% { box-shadow: 0 0 0 4px transparent; }\n\t}\n\n\t.run-badge {\n\t\tposition: absolute;\n\t\ttop: -6px;\n\t\tright: -6px;\n\t\tmin-width: 14px;\n\t\theight: 14px;\n\t\tbackground: var(--color-accent);\n\t\tcolor: var(--button-primary-text-color);\n\t\tfont-size: 9px;\n\t\tfont-weight: 700;\n\t\tborder-radius: 7px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tpadding: 0 3px;\n\t}\n\n\t.node-body {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tpadding-top: 8px;\n\t\tpadding-bottom: 8px;\n\t\tmin-height: 30px;\n\t\toverflow: hidden;\n\t}\n\n\t.ports-left, .ports-right {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tmin-width: 0;\n\t\tmax-width: 50%;\n\t}\n\n\t.ports-right {\n\t\talign-items: flex-end;\n\t}\n\n\t.port-row {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 6px;\n\t\theight: 22px;\n\t\tpadding: 0 10px;\n\t\tmin-width: 0;\n\t\tmax-width: 100%;\n\t}\n\n\t.port-dot {\n\t\twidth: 8px;\n\t\theight: 8px;\n\t\tborder-radius: 50%;\n\t\tflex-shrink: 0;\n\t}\n\n\t.port-dot.input {\n\t\tbackground: linear-gradient(135deg, var(--color-accent) 0%, color-mix(in srgb, var(--color-accent) 80%, black) 100%);\n\t\tbox-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 50%, transparent);\n\t}\n\n\t.port-dot.output {\n\t\tbackground: linear-gradient(135deg, var(--color-accent-soft) 0%, var(--color-accent) 100%);\n\t\tbox-shadow: 0 0 6px color-mix(in srgb, var(--color-accent-soft) 50%, transparent);\n\t}\n\n\t.port-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 500;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t\tmax-width: 100%;\n\t}\n\n\t.node-error {\n\t\tpadding: 8px 10px;\n\t\tborder-top: 1px solid color-mix(in srgb, var(--error-border-color) 20%, transparent);\n\t\tbackground: color-mix(in srgb, var(--error-background-fill) 5%, transparent);\n\t\tmax-height: 200px;\n\t\toverflow-y: auto;\n\t}\n\n\t.node-error-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--error-text-color);\n\t\ttext-transform: uppercase;\n\t\tletter-spacing: 0.5px;\n\t\tmargin-bottom: 4px;\n\t}\n\n\t.node-error-message {\n\t\tfont-size: 11px;\n\t\tcolor: var(--error-border-color);\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\twhite-space: pre-wrap;\n\t\tword-break: break-word;\n\t\tline-height: 1.4;\n\t}\n\n\t.embedded-components {\n\t\tpadding: 8px 10px;\n\t\tborder-top: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);\n\t\tmax-height: 200px;\n\t\toverflow-y: auto;\n\t}\n\n\t.variants-accordion {\n\t\tborder-top: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);\n\t\tmax-height: 350px;\n\t\toverflow-y: auto;\n\t}\n\n\t.variant-card {\n\t\tborder-bottom: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);\n\t\tcursor: pointer;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.variant-card:last-child {\n\t\tborder-bottom: none;\n\t}\n\n\t.variant-card:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 3%, transparent);\n\t}\n\n\t.variant-card.selected {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 6%, transparent);\n\t}\n\n\t.variant-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 10px 12px;\n\t}\n\n\t.variant-radio {\n\t\tfont-size: 12px;\n\t\tcolor: var(--color-accent);\n\t\twidth: 14px;\n\t\tflex-shrink: 0;\n\t}\n\n\t.variant-radio.checked {\n\t\tfont-weight: 700;\n\t}\n\n\t.variant-name {\n\t\tfont-size: 11px;\n\t\tfont-weight: 500;\n\t\tcolor: var(--body-text-color-subdued);\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t}\n\n\t.variant-card.selected .variant-name {\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.variant-inputs {\n\t\tpadding: 0 12px 10px 34px;\n\t}\n\n\t.result-selector {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tgap: 8px;\n\t\tpadding: 6px 10px;\n\t\tbackground: color-mix(in srgb, var(--color-accent) 5%, transparent);\n\t\tborder-top: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t}\n\n\t.result-nav {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t\tcolor: var(--color-accent);\n\t\tfont-size: 14px;\n\t\tfont-weight: 600;\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.result-nav:hover:not(:disabled) {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 25%, transparent);\n\t}\n\n\t.result-nav:disabled {\n\t\topacity: 0.3;\n\t\tcursor: not-allowed;\n\t}\n\n\t.result-counter {\n\t\tfont-size: 11px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tmin-width: 32px;\n\t\ttext-align: center;\n\t}\n\n\t.user-controls-wrapper {\n\t\tposition: fixed;\n\t\ttop: 16px;\n\t\tright: 16px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 12px;\n\t\tz-index: 2000;\n\t\tcursor: default;\n\t\tpointer-events: auto;\n\t}\n\n\t.status-pill, .hf-user, .login-section {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\theight: 36px;\n\t\tpadding: 0 12px;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 90%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 8px;\n\t\tbackdrop-filter: blur(4px);\n\t\tfont-size: 13px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.status-pill.info {\n\t\tborder-color: var(--color-accent);\n\t\tcolor: var(--color-accent);\n\t\tfont-weight: 600;\n\t}\n\n\t.theme-btn {\n\t\twidth: 36px;\n\t\theight: 36px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tbackground: color-mix(in srgb, var(--block-background-fill) 90%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tborder-radius: 8px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tcursor: pointer;\n\t\ttransition: all 0.2s;\n\t\tbackdrop-filter: blur(4px);\n\t}\n\n\t.theme-btn:hover {\n\t\tborder-color: var(--color-accent);\n\t\tcolor: var(--color-accent);\n\t\tbackground: color-mix(in srgb, var(--color-accent) 5%, transparent);\n\t}\n\n\t.canvas:not(.dark) .zoom-btn,\n\t.canvas:not(.dark) .zoom-level,\n\t.canvas:not(.dark) .sheet-current,\n\t.canvas:not(.dark) .sheet-action-btn,\n\t.canvas:not(.dark) .title-separator {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.node::-webkit-scrollbar,\n\t.embedded-components::-webkit-scrollbar {\n\t\twidth: 6px;\n\t\theight: 6px;\n\t}\n\n\t.node::-webkit-scrollbar-track,\n\t.embedded-components::-webkit-scrollbar-track {\n\t\tbackground: transparent;\n\t}\n\n\t.node::-webkit-scrollbar-thumb,\n\t.embedded-components::-webkit-scrollbar-thumb {\n\t\tbackground-color: rgba(136, 136, 136, 0.3);\n\t\tborder-radius: 10px;\n\t\tborder: 1px solid transparent;\n\t\tbackground-clip: content-box;\n\t}\n\n\t.node::-webkit-scrollbar-thumb:hover,\n\t.embedded-components::-webkit-scrollbar-thumb:hover {\n\t\tbackground-color: var(--color-accent);\n\t}\n\n\t.node,\n\t.embedded-components {\n\t\tscrollbar-width: thin;\n\t\tscrollbar-color: rgba(136, 136, 136, 0.4) transparent;\n\t}\n\n</style>\n"
  },
  {
    "path": "daggr/frontend/src/components/Audio.svelte",
    "content": "<script lang=\"ts\">\n\timport AudioPlayer from './AudioPlayer.svelte';\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: any;\n\t\tid: string;\n\t\teditable?: boolean;\n\t\tonchange?: (value: any) => void;\n\t}\n\n\tlet { label, value, id, editable = true, onchange }: Props = $props();\n\n\tlet fileInputEl: HTMLInputElement | null = $state(null);\n\tlet isRecording = $state(false);\n\tlet mediaRecorder: MediaRecorder | null = $state(null);\n\tlet recordedChunks: Blob[] = $state([]);\n\tlet recordingTime = $state(0);\n\tlet recordingTimer: number | null = $state(null);\n\n\tlet src = $derived.by(() => {\n\t\tif (!value) return null;\n\t\tif (typeof value === 'string') return value;\n\t\tif (value.url) return value.url;\n\t\tif (value instanceof Blob) return URL.createObjectURL(value);\n\t\treturn null;\n\t});\n\n\tfunction triggerUpload() {\n\t\tfileInputEl?.click();\n\t}\n\n\tfunction handleFileSelect(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst file = target.files?.[0];\n\t\tif (file) {\n\t\t\tonchange?.(file);\n\t\t}\n\t\ttarget.value = '';\n\t}\n\n\tasync function startRecording() {\n\t\ttry {\n\t\t\tconst stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n\t\t\tmediaRecorder = new MediaRecorder(stream);\n\t\t\trecordedChunks = [];\n\t\t\trecordingTime = 0;\n\n\t\t\tmediaRecorder.ondataavailable = (e) => {\n\t\t\t\tif (e.data.size > 0) {\n\t\t\t\t\trecordedChunks.push(e.data);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tmediaRecorder.onstop = () => {\n\t\t\t\tconst blob = new Blob(recordedChunks, { type: 'audio/webm' });\n\t\t\t\tonchange?.(blob);\n\t\t\t\tstream.getTracks().forEach(track => track.stop());\n\t\t\t\tif (recordingTimer) {\n\t\t\t\t\tclearInterval(recordingTimer);\n\t\t\t\t\trecordingTimer = null;\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tmediaRecorder.start();\n\t\t\tisRecording = true;\n\t\t\t\n\t\t\trecordingTimer = window.setInterval(() => {\n\t\t\t\trecordingTime += 1;\n\t\t\t}, 1000);\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to access microphone:', e);\n\t\t}\n\t}\n\n\tfunction stopRecording() {\n\t\tif (mediaRecorder && mediaRecorder.state !== 'inactive') {\n\t\t\tmediaRecorder.stop();\n\t\t}\n\t\tisRecording = false;\n\t}\n\n\tfunction clearAudio() {\n\t\tonchange?.(null);\n\t}\n\n\tfunction formatTime(seconds: number): string {\n\t\tconst mins = Math.floor(seconds / 60);\n\t\tconst secs = seconds % 60;\n\t\treturn `${mins}:${secs.toString().padStart(2, '0')}`;\n\t}\n\n\tasync function downloadAudio() {\n\t\tif (!src) return;\n\t\ttry {\n\t\t\tconst response = await fetch(src);\n\t\t\tconst blob = await response.blob();\n\t\t\tconst blobUrl = URL.createObjectURL(blob);\n\t\t\tconst link = document.createElement('a');\n\t\t\tlink.href = blobUrl;\n\t\t\t\n\t\t\tlet ext = 'wav';\n\t\t\ttry {\n\t\t\t\tconst urlPath = new URL(src, window.location.origin).pathname;\n\t\t\t\tconst urlExt = urlPath.split('.').pop()?.toLowerCase();\n\t\t\t\tif (urlExt && ['wav', 'mp3', 'webm', 'ogg', 'flac', 'm4a', 'aac'].includes(urlExt)) {\n\t\t\t\t\text = urlExt;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tconst blobExt = blob.type.split('/')[1]?.split(';')[0];\n\t\t\t\tif (blobExt && blobExt !== 'octet-stream') {\n\t\t\t\t\text = blobExt;\n\t\t\t\t}\n\t\t\t}\n\t\t\t\n\t\t\tlink.download = `${label || 'audio'}.${ext}`;\n\t\t\tdocument.body.appendChild(link);\n\t\t\tlink.click();\n\t\t\tdocument.body.removeChild(link);\n\t\t\tURL.revokeObjectURL(blobUrl);\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to download audio:', e);\n\t\t}\n\t}\n</script>\n\n<div class=\"gr-audio-wrap\">\n\t<input\n\t\tbind:this={fileInputEl}\n\t\ttype=\"file\"\n\t\taccept=\"audio/*\"\n\t\tstyle=\"display: none\"\n\t\tonchange={handleFileSelect}\n\t/>\n\t\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"audio-actions\">\n\t\t\t{#if editable && !isRecording && !src}\n\t\t\t\t<button class=\"action-btn\" onclick={triggerUpload} title=\"Upload audio\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={startRecording} title=\"Record from microphone\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z\"/>\n\t\t\t\t\t\t<path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"23\"/>\n\t\t\t\t\t\t<line x1=\"8\" y1=\"23\" x2=\"16\" y2=\"23\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t\t{#if isRecording}\n\t\t\t\t<button class=\"action-btn recording\" onclick={stopRecording} title=\"Stop recording\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n\t\t\t\t\t\t<rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"2\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t\t{#if src && !isRecording}\n\t\t\t\t<button class=\"action-btn\" onclick={clearAudio} title=\"Clear audio\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={downloadAudio} title=\"Download\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"7 10 12 15 17 10\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\t\n\t{#if isRecording}\n\t\t<div class=\"recording-indicator\">\n\t\t\t<span class=\"recording-dot\"></span>\n\t\t\t<span class=\"recording-time\">{formatTime(recordingTime)}</span>\n\t\t\t<span class=\"recording-text\">Recording...</span>\n\t\t</div>\n\t{:else if src}\n\t\t<AudioPlayer {src} {id} />\n\t{:else}\n\t\t<div class=\"gr-empty\">No audio</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-audio-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.audio-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.action-btn.recording {\n\t\tbackground: var(--error-border-color);\n\t\tanimation: pulse-recording 1.5s ease-in-out infinite;\n\t}\n\n\t.action-btn.recording svg {\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t.action-btn.recording:hover {\n\t\tbackground: var(--error-text-color);\n\t}\n\n\t@keyframes pulse-recording {\n\t\t0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--error-border-color) 40%, transparent); }\n\t\t50% { box-shadow: 0 0 0 4px transparent; }\n\t}\n\n\t.recording-indicator {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 12px 10px;\n\t\tbackground: linear-gradient(135deg, var(--block-background-fill) 0%, color-mix(in srgb, var(--block-background-fill) 90%, black) 100%);\n\t}\n\n\t.recording-dot {\n\t\twidth: 10px;\n\t\theight: 10px;\n\t\tbackground: var(--error-border-color);\n\t\tborder-radius: 50%;\n\t\tanimation: blink 1s ease-in-out infinite;\n\t}\n\n\t@keyframes blink {\n\t\t0%, 100% { opacity: 1; }\n\t\t50% { opacity: 0.3; }\n\t}\n\n\t.recording-time {\n\t\tfont-size: 12px;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tcolor: var(--error-border-color);\n\t\tfont-weight: 600;\n\t}\n\n\t.recording-text {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/AudioPlayer.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tsrc: string;\n\t\tid: string;\n\t\tcompact?: boolean;\n\t}\n\n\tlet { src, id, compact = false }: Props = $props();\n\n\tlet audioEl = $state<HTMLAudioElement | null>(null);\n\tlet playing = $state(false);\n\tlet currentTime = $state(0);\n\tlet duration = $state(0);\n\n\tfunction formatTime(seconds: number): string {\n\t\tif (!seconds || !isFinite(seconds)) return '0:00';\n\t\tconst mins = Math.floor(seconds / 60);\n\t\tconst secs = Math.floor(seconds % 60);\n\t\treturn `${mins}:${secs.toString().padStart(2, '0')}`;\n\t}\n\n\tfunction togglePlay(e: MouseEvent) {\n\t\te.stopPropagation();\n\t\tif (!audioEl) return;\n\t\tif (audioEl.paused) {\n\t\t\taudioEl.play();\n\t\t} else {\n\t\t\taudioEl.pause();\n\t\t}\n\t}\n\n\tfunction seek(e: MouseEvent) {\n\t\te.stopPropagation();\n\t\tif (!audioEl || !duration) return;\n\t\tconst rect = (e.currentTarget as HTMLElement).getBoundingClientRect();\n\t\tconst x = e.clientX - rect.left;\n\t\tconst percent = x / rect.width;\n\t\taudioEl.currentTime = percent * duration;\n\t}\n\n\tfunction handleLoadedMetadata() {\n\t\tif (audioEl) duration = audioEl.duration;\n\t}\n\n\tfunction handleTimeUpdate() {\n\t\tif (audioEl) currentTime = audioEl.currentTime;\n\t}\n\n\tfunction handlePlay() {\n\t\tplaying = true;\n\t}\n\n\tfunction handlePause() {\n\t\tplaying = false;\n\t}\n\n\tfunction handleEnded() {\n\t\tplaying = false;\n\t\tcurrentTime = 0;\n\t}\n</script>\n\n<audio \n\tbind:this={audioEl}\n\t{src}\n\tpreload=\"metadata\"\n\tstyle=\"display:none\"\n\tonloadedmetadata={handleLoadedMetadata}\n\tontimeupdate={handleTimeUpdate}\n\tonplay={handlePlay}\n\tonpause={handlePause}\n\tonended={handleEnded}\n></audio>\n\n<div class=\"audio-player\" class:compact>\n\t<button class=\"play-btn\" onclick={togglePlay}>\n\t\t{#if playing}\n\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n\t\t\t\t<rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"/>\n\t\t\t\t<rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"/>\n\t\t\t</svg>\n\t\t{:else}\n\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n\t\t\t\t<path d=\"M8 5v14l11-7z\"/>\n\t\t\t</svg>\n\t\t{/if}\n\t</button>\n\t<div class=\"progress\" onclick={seek} role=\"slider\" tabindex=\"0\" aria-valuenow={currentTime} aria-valuemin=\"0\" aria-valuemax={duration}>\n\t\t<div class=\"progress-fill\" style=\"width: {duration ? (currentTime / duration) * 100 : 0}%\"></div>\n\t</div>\n\t<span class=\"time\">{formatTime(currentTime)} / {formatTime(duration)}</span>\n</div>\n\n<style>\n\t.audio-player {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 8px 10px;\n\t\tbackground: linear-gradient(135deg, var(--block-background-fill) 0%, color-mix(in srgb, var(--block-background-fill) 90%, black) 100%);\n\t}\n\n\t.audio-player.compact {\n\t\tpadding: 6px 8px;\n\t\tgap: 6px;\n\t\tflex: 1;\n\t}\n\n\t.play-btn {\n\t\twidth: 28px;\n\t\theight: 28px;\n\t\tborder: none;\n\t\tbackground: var(--color-accent);\n\t\tcolor: var(--button-primary-text-color);\n\t\tborder-radius: 50%;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tflex-shrink: 0;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.play-btn:hover {\n\t\tbackground: var(--color-accent-soft);\n\t\ttransform: scale(1.05);\n\t}\n\n\t.play-btn svg {\n\t\twidth: 14px;\n\t\theight: 14px;\n\t}\n\n\t.compact .play-btn {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t}\n\n\t.compact .play-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t}\n\n\t.progress {\n\t\tflex: 1;\n\t\theight: 6px;\n\t\tbackground: var(--border-color-primary);\n\t\tborder-radius: 3px;\n\t\tcursor: pointer;\n\t\tposition: relative;\n\t\toverflow: hidden;\n\t}\n\n\t.progress:hover {\n\t\theight: 8px;\n\t}\n\n\t.progress-fill {\n\t\theight: 100%;\n\t\tbackground: linear-gradient(90deg, var(--color-accent) 0%, var(--color-accent-soft) 100%);\n\t\tborder-radius: 3px;\n\t\ttransition: width 0.1s linear;\n\t}\n\n\t.time {\n\t\tfont-size: 10px;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tmin-width: 70px;\n\t\ttext-align: right;\n\t\tflex-shrink: 0;\n\t}\n\n\t.compact .time {\n\t\tfont-size: 9px;\n\t\tmin-width: 60px;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Button.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tvalue: string;\n\t\tvariant?: 'primary' | 'secondary' | 'stop';\n\t\tsize?: 'sm' | 'md' | 'lg';\n\t\tdisabled?: boolean;\n\t\tonclick?: () => void;\n\t}\n\n\tlet { \n\t\tvalue, \n\t\tvariant = 'primary', \n\t\tsize = 'md', \n\t\tdisabled = false,\n\t\tonclick \n\t}: Props = $props();\n</script>\n\n<button \n\tclass=\"gr-button {variant} {size}\" \n\t{disabled}\n\tonclick={() => onclick?.()}\n>\n\t{value}\n</button>\n\n<style>\n\t.gr-button {\n\t\tfont-family: inherit;\n\t\tfont-weight: 500;\n\t\tborder: none;\n\t\tborder-radius: 6px;\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t\tdisplay: inline-flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t}\n\n\t.gr-button:disabled {\n\t\topacity: 0.5;\n\t\tcursor: not-allowed;\n\t}\n\n\t.sm {\n\t\tpadding: 4px 10px;\n\t\tfont-size: 10px;\n\t}\n\n\t.md {\n\t\tpadding: 6px 14px;\n\t\tfont-size: 11px;\n\t}\n\n\t.lg {\n\t\tpadding: 8px 18px;\n\t\tfont-size: 12px;\n\t}\n\n\t.primary {\n\t\tbackground: var(--color-accent);\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t.primary:hover:not(:disabled) {\n\t\tbackground: var(--button-primary-background-fill-hover);\n\t}\n\n\t.primary:active:not(:disabled) {\n\t\tbackground: var(--button-primary-background-fill-hover);\n\t\tfilter: brightness(0.9);\n\t}\n\n\t.secondary {\n\t\tbackground: var(--button-secondary-background-fill);\n\t\tcolor: var(--button-secondary-text-color);\n\t\tborder: 1px solid var(--button-secondary-border-color);\n\t}\n\n\t.secondary:hover:not(:disabled) {\n\t\tbackground: var(--button-secondary-background-fill-hover);\n\t\tborder-color: var(--button-secondary-border-color-hover);\n\t}\n\n\t.secondary:active:not(:disabled) {\n\t\tbackground: var(--button-secondary-background-fill-hover);\n\t\tfilter: brightness(0.9);\n\t}\n\n\t.stop {\n\t\tbackground: var(--error-border-color);\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t.stop:hover:not(:disabled) {\n\t\tbackground: var(--error-text-color);\n\t}\n\n\t.stop:active:not(:disabled) {\n\t\tbackground: var(--error-text-color);\n\t\tfilter: brightness(0.9);\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Checkbox.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: boolean;\n\t\tdisabled?: boolean;\n\t\tonchange?: (value: boolean) => void;\n\t}\n\n\tlet { label, value, disabled = false, onchange }: Props = $props();\n\n\tfunction handleChange(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tonchange?.(target.checked);\n\t}\n</script>\n\n<div class=\"gr-checkbox-wrap\">\n\t<label class=\"checkbox-container\" class:disabled>\n\t\t<input\n\t\t\ttype=\"checkbox\"\n\t\t\tchecked={value}\n\t\t\t{disabled}\n\t\t\tonchange={handleChange}\n\t\t/>\n\t\t<span class=\"checkmark\"></span>\n\t\t<span class=\"gr-label\">{label}</span>\n\t</label>\n</div>\n\n<style>\n\t.gr-checkbox-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\tpadding: 8px 10px;\n\t}\n\n\t.checkbox-container {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tcursor: pointer;\n\t\tposition: relative;\n\t}\n\n\t.checkbox-container.disabled {\n\t\tcursor: not-allowed;\n\t\topacity: 0.6;\n\t}\n\n\t.checkbox-container input {\n\t\tposition: absolute;\n\t\topacity: 0;\n\t\tcursor: pointer;\n\t\theight: 0;\n\t\twidth: 0;\n\t}\n\n\t.checkmark {\n\t\twidth: 14px;\n\t\theight: 14px;\n\t\tbackground: var(--checkbox-background-color);\n\t\tborder: 1px solid var(--checkbox-border-color);\n\t\tborder-radius: 3px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: all 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.checkbox-container:hover .checkmark {\n\t\tborder-color: var(--checkbox-border-color-hover);\n\t}\n\n\t.checkbox-container input:checked ~ .checkmark {\n\t\tbackground: var(--color-accent);\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.checkmark::after {\n\t\tcontent: '';\n\t\tdisplay: none;\n\t\twidth: 4px;\n\t\theight: 7px;\n\t\tborder: solid var(--button-primary-text-color);\n\t\tborder-width: 0 2px 2px 0;\n\t\ttransform: rotate(45deg);\n\t\tmargin-bottom: 2px;\n\t}\n\n\t.checkbox-container input:checked ~ .checkmark::after {\n\t\tdisplay: block;\n\t}\n\n\t.checkbox-container:focus-within .checkmark {\n\t\tborder-color: var(--color-accent);\n\t\tbox-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t}\n\n\t.gr-label {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.checkbox-container.disabled .gr-label {\n\t\tcolor: var(--neutral-500);\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/CheckboxGroup.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tchoices: [string, string | number][];\n\t\tvalue: (string | number)[];\n\t\tdisabled?: boolean;\n\t\tonchange?: (value: (string | number)[]) => void;\n\t}\n\n\tlet { label, choices, value, disabled = false, onchange }: Props = $props();\n\n\tfunction toggleChoice(internalValue: string | number) {\n\t\tif (disabled) return;\n\t\tlet newValue: (string | number)[];\n\t\tif (value.includes(internalValue)) {\n\t\t\tnewValue = value.filter(v => v !== internalValue);\n\t\t} else {\n\t\t\tnewValue = [...value, internalValue];\n\t\t}\n\t\tonchange?.(newValue);\n\t}\n</script>\n\n<div class=\"gr-checkboxgroup-wrap\">\n\t<span class=\"gr-label\">{label}</span>\n\t<div class=\"choices\">\n\t\t{#each choices as [displayValue, internalValue]}\n\t\t\t<label class=\"choice\" class:disabled class:selected={value.includes(internalValue)}>\n\t\t\t\t<input\n\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\tchecked={value.includes(internalValue)}\n\t\t\t\t\t{disabled}\n\t\t\t\t\tonchange={() => toggleChoice(internalValue)}\n\t\t\t\t/>\n\t\t\t\t<span class=\"checkmark\"></span>\n\t\t\t\t<span class=\"choice-label\">{displayValue}</span>\n\t\t\t</label>\n\t\t{/each}\n\t</div>\n</div>\n\n<style>\n\t.gr-checkboxgroup-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 4px;\n\t}\n\n\t.choices {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 6px;\n\t\tpadding: 0 10px 8px;\n\t}\n\n\t.choice {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 6px;\n\t\tpadding: 4px 8px;\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.choice:hover:not(.disabled) {\n\t\tborder-color: var(--border-color-primary);\n\t\tbackground: var(--background-fill-secondary);\n\t}\n\n\t.choice.selected {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tborder-color: color-mix(in srgb, var(--color-accent) 40%, transparent);\n\t}\n\n\t.choice.disabled {\n\t\tcursor: not-allowed;\n\t\topacity: 0.6;\n\t}\n\n\t.choice input {\n\t\tposition: absolute;\n\t\topacity: 0;\n\t\tcursor: pointer;\n\t\theight: 0;\n\t\twidth: 0;\n\t}\n\n\t.checkmark {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tbackground: var(--checkbox-background-color);\n\t\tborder: 1px solid var(--checkbox-border-color);\n\t\tborder-radius: 2px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: all 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.choice input:checked ~ .checkmark {\n\t\tbackground: var(--color-accent);\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.checkmark::after {\n\t\tcontent: '';\n\t\tdisplay: none;\n\t\twidth: 3px;\n\t\theight: 6px;\n\t\tborder: solid var(--button-primary-text-color);\n\t\tborder-width: 0 1.5px 1.5px 0;\n\t\ttransform: rotate(45deg);\n\t\tmargin-bottom: 1px;\n\t}\n\n\t.choice input:checked ~ .checkmark::after {\n\t\tdisplay: block;\n\t}\n\n\t.choice-label {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Code.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: string;\n\t\tlanguage?: string;\n\t\tlineNumbers?: boolean;\n\t\teditable?: boolean;\n\t\tonchange?: (value: string) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tlanguage = 'text',\n\t\tlineNumbers = true,\n\t\teditable = false,\n\t\tonchange \n\t}: Props = $props();\n\n\tlet copySuccess = $state(false);\n\tlet containerEl: HTMLDivElement | null = $state(null);\n\tlet isFullscreen = $state(false);\n\n\tlet lines = $derived(value ? value.split('\\n') : ['']);\n\n\tfunction openFullscreen() {\n\t\tif (!containerEl) return;\n\t\tif (containerEl.requestFullscreen) {\n\t\t\tcontainerEl.requestFullscreen();\n\t\t} else if ((containerEl as any).webkitRequestFullscreen) {\n\t\t\t(containerEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction handleFullscreenChange() {\n\t\tisFullscreen = !!document.fullscreenElement;\n\t}\n\n\tfunction copyCode() {\n\t\tnavigator.clipboard.writeText(value).then(() => {\n\t\t\tcopySuccess = true;\n\t\t\tsetTimeout(() => copySuccess = false, 1500);\n\t\t});\n\t}\n\n\tfunction downloadCode() {\n\t\tconst blob = new Blob([value], { type: 'text/plain' });\n\t\tconst url = URL.createObjectURL(blob);\n\t\tconst link = document.createElement('a');\n\t\tlink.href = url;\n\t\tlink.download = `${label || 'code'}.${language}`;\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t\tURL.revokeObjectURL(url);\n\t}\n\n\tfunction handleInput(e: Event) {\n\t\tconst target = e.target as HTMLTextAreaElement;\n\t\tonchange?.(target.value);\n\t}\n</script>\n\n<svelte:document onfullscreenchange={handleFullscreenChange} />\n\n<div class=\"gr-code-wrap\" class:fullscreen={isFullscreen} bind:this={containerEl}>\n\t<div class=\"gr-header\">\n\t\t<div class=\"header-left\">\n\t\t\t<span class=\"gr-label\">{label}</span>\n\t\t\t<span class=\"language-badge\">{language}</span>\n\t\t</div>\n\t\t<div class=\"code-actions\">\n\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t\t<button class=\"action-btn\" class:success={copySuccess} onclick={copyCode} title=\"Copy code\">\n\t\t\t\t{#if copySuccess}\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polyline points=\"20 6 9 17 4 12\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t{:else}\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"/>\n\t\t\t\t\t\t<path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t{/if}\n\t\t\t</button>\n\t\t\t<button class=\"action-btn\" onclick={downloadCode} title=\"Download\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t<polyline points=\"7 10 12 15 17 10\"/>\n\t\t\t\t\t<line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t</div>\n\t</div>\n\n\t<div class=\"code-container\">\n\t\t{#if editable}\n\t\t\t<textarea\n\t\t\t\tclass=\"code-editor\"\n\t\t\t\tvalue={value}\n\t\t\t\toninput={handleInput}\n\t\t\t\tspellcheck=\"false\"\n\t\t\t></textarea>\n\t\t{:else}\n\t\t\t<div class=\"code-display\">\n\t\t\t\t{#if lineNumbers}\n\t\t\t\t\t<div class=\"line-numbers\">\n\t\t\t\t\t\t{#each lines as _, i}\n\t\t\t\t\t\t\t<span>{i + 1}</span>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</div>\n\t\t\t\t{/if}\n\t\t\t\t<pre class=\"code-content\"><code>{value}</code></pre>\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n</div>\n\n<style>\n\t.gr-code-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-code-wrap.fullscreen {\n\t\tborder-radius: 0;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\theight: 100vh;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t\tbackground: var(--background-fill-secondary);\n\t}\n\n\t.header-left {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.language-badge {\n\t\tfont-size: 9px;\n\t\tpadding: 2px 6px;\n\t\tbackground: var(--border-color-primary);\n\t\tcolor: var(--body-text-color-subdued);\n\t\tborder-radius: 3px;\n\t\ttext-transform: lowercase;\n\t}\n\n\t.code-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.action-btn.success svg {\n\t\tcolor: var(--primary-500, #22c55e);\n\t}\n\n\t.code-container {\n\t\tmax-height: 300px;\n\t\toverflow: auto;\n\t}\n\n\t.gr-code-wrap.fullscreen .code-container {\n\t\tmax-height: none;\n\t\tflex: 1;\n\t}\n\n\t.gr-code-wrap.fullscreen .code-content,\n\t.gr-code-wrap.fullscreen .code-editor {\n\t\tfont-size: 14px;\n\t}\n\n\t.gr-code-wrap.fullscreen .line-numbers span {\n\t\tfont-size: 14px;\n\t}\n\n\t.code-display {\n\t\tdisplay: flex;\n\t}\n\n\t.line-numbers {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tpadding: 10px 0;\n\t\tbackground: var(--background-fill-secondary);\n\t\tborder-right: 1px solid var(--border-color-primary);\n\t\tuser-select: none;\n\t\tflex-shrink: 0;\n\t}\n\n\t.line-numbers span {\n\t\tpadding: 0 10px;\n\t\tfont-family: 'SF Mono', Monaco, Consolas, monospace;\n\t\tfont-size: 11px;\n\t\tline-height: 1.5;\n\t\tcolor: var(--input-placeholder-color);\n\t\ttext-align: right;\n\t\tmin-width: 30px;\n\t}\n\n\t.code-content {\n\t\tflex: 1;\n\t\tmargin: 0;\n\t\tpadding: 10px;\n\t\tfont-family: 'SF Mono', Monaco, Consolas, monospace;\n\t\tfont-size: 11px;\n\t\tline-height: 1.5;\n\t\tcolor: var(--body-text-color);\n\t\toverflow-x: auto;\n\t\twhite-space: pre;\n\t}\n\n\t.code-content code {\n\t\tfont-family: inherit;\n\t}\n\n\t.code-editor {\n\t\twidth: 100%;\n\t\tmin-height: 150px;\n\t\tpadding: 10px;\n\t\tfont-family: 'SF Mono', Monaco, Consolas, monospace;\n\t\tfont-size: 11px;\n\t\tline-height: 1.5;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\toutline: none;\n\t\tresize: vertical;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/ColorPicker.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: string;\n\t\tdisabled?: boolean;\n\t\tonchange?: (value: string) => void;\n\t}\n\n\tlet { label, value, disabled = false, onchange }: Props = $props();\n\n\tlet inputEl: HTMLInputElement | null = $state(null);\n\n\tfunction handleChange(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tonchange?.(target.value);\n\t}\n\n\tfunction handleTextInput(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tlet val = target.value;\n\t\tif (!val.startsWith('#')) val = '#' + val;\n\t\tif (/^#[0-9A-Fa-f]{6}$/.test(val)) {\n\t\t\tonchange?.(val);\n\t\t}\n\t}\n\n\tfunction openPicker() {\n\t\tinputEl?.click();\n\t}\n</script>\n\n<div class=\"gr-colorpicker-wrap\">\n\t<span class=\"gr-label\">{label}</span>\n\t<div class=\"picker-container\">\n\t\t<input\n\t\t\tbind:this={inputEl}\n\t\t\ttype=\"color\"\n\t\t\tclass=\"color-input\"\n\t\t\t{value}\n\t\t\t{disabled}\n\t\t\tonchange={handleChange}\n\t\t/>\n\t\t<button \n\t\t\tclass=\"color-preview\" \n\t\t\tstyle:background-color={value}\n\t\t\tonclick={openPicker}\n\t\t\t{disabled}\n\t\t></button>\n\t\t<input\n\t\t\ttype=\"text\"\n\t\t\tclass=\"hex-input\"\n\t\t\tvalue={value.toUpperCase()}\n\t\t\t{disabled}\n\t\t\toninput={handleTextInput}\n\t\t/>\n\t</div>\n</div>\n\n<style>\n\t.gr-colorpicker-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 0;\n\t}\n\n\t.picker-container {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 6px 10px 8px;\n\t}\n\n\t.color-input {\n\t\tposition: absolute;\n\t\twidth: 0;\n\t\theight: 0;\n\t\topacity: 0;\n\t\tpointer-events: none;\n\t}\n\n\t.color-preview {\n\t\twidth: 28px;\n\t\theight: 28px;\n\t\tborder: 2px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\ttransition: border-color 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.color-preview:hover:not(:disabled) {\n\t\tborder-color: var(--border-color-primary);\n\t}\n\n\t.color-preview:disabled {\n\t\tcursor: not-allowed;\n\t\topacity: 0.6;\n\t}\n\n\t.hex-input {\n\t\tflex: 1;\n\t\tmin-width: 0;\n\t\tpadding: 6px 8px;\n\t\tfont-size: 11px;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\toutline: none;\n\t}\n\n\t.hex-input:focus {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.hex-input:disabled {\n\t\topacity: 0.6;\n\t\tcursor: not-allowed;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Dataframe.svelte",
    "content": "<script lang=\"ts\">\n\tinterface DataframeValue {\n\t\theaders: string[];\n\t\tdata: (string | number | boolean | null)[][];\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: DataframeValue | null;\n\t\teditable?: boolean;\n\t\tmaxHeight?: number;\n\t\twrap?: boolean;\n\t\tonchange?: (value: DataframeValue) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\teditable = false, \n\t\tmaxHeight = 300,\n\t\twrap = true,\n\t\tonchange \n\t}: Props = $props();\n\n\tlet editingCell: { row: number; col: number } | null = $state(null);\n\tlet editValue = $state('');\n\tlet containerEl: HTMLDivElement | null = $state(null);\n\tlet isFullscreen = $state(false);\n\n\tfunction openFullscreen() {\n\t\tif (!containerEl) return;\n\t\tif (containerEl.requestFullscreen) {\n\t\t\tcontainerEl.requestFullscreen();\n\t\t} else if ((containerEl as any).webkitRequestFullscreen) {\n\t\t\t(containerEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction handleFullscreenChange() {\n\t\tisFullscreen = !!document.fullscreenElement;\n\t}\n\n\tfunction startEdit(row: number, col: number, currentValue: any) {\n\t\tif (!editable) return;\n\t\teditingCell = { row, col };\n\t\teditValue = String(currentValue ?? '');\n\t}\n\n\tfunction commitEdit() {\n\t\tif (!editingCell || !value) return;\n\t\tconst { row, col } = editingCell;\n\t\tconst newData = value.data.map((r, ri) => \n\t\t\tri === row ? r.map((c, ci) => ci === col ? editValue : c) : r\n\t\t);\n\t\tonchange?.({ ...value, data: newData });\n\t\teditingCell = null;\n\t}\n\n\tfunction cancelEdit() {\n\t\teditingCell = null;\n\t}\n\n\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\tif (e.key === 'Enter') {\n\t\t\tcommitEdit();\n\t\t} else if (e.key === 'Escape') {\n\t\t\tcancelEdit();\n\t\t}\n\t}\n\n\tfunction copyTable() {\n\t\tif (!value) return;\n\t\tconst text = [\n\t\t\tvalue.headers.join('\\t'),\n\t\t\t...value.data.map(row => row.map(c => c ?? '').join('\\t'))\n\t\t].join('\\n');\n\t\tnavigator.clipboard.writeText(text);\n\t}\n</script>\n\n<svelte:document onfullscreenchange={handleFullscreenChange} />\n\n<div class=\"gr-dataframe-wrap\" class:fullscreen={isFullscreen} bind:this={containerEl}>\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"table-actions\">\n\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t\t{#if value}\n\t\t\t\t<button class=\"action-btn\" onclick={copyTable} title=\"Copy to clipboard\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"/>\n\t\t\t\t\t\t<path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\n\t{#if value && value.data.length > 0}\n\t\t<div class=\"table-container\" style:max-height=\"{maxHeight}px\">\n\t\t\t<table class:wrap>\n\t\t\t\t<thead>\n\t\t\t\t\t<tr>\n\t\t\t\t\t\t<th class=\"row-num\">#</th>\n\t\t\t\t\t\t{#each value.headers as header}\n\t\t\t\t\t\t\t<th>{header}</th>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</tr>\n\t\t\t\t</thead>\n\t\t\t\t<tbody>\n\t\t\t\t\t{#each value.data as row, ri}\n\t\t\t\t\t\t<tr>\n\t\t\t\t\t\t\t<td class=\"row-num\">{ri + 1}</td>\n\t\t\t\t\t\t\t{#each row as cell, ci}\n\t\t\t\t\t\t\t\t<td \n\t\t\t\t\t\t\t\t\tclass:editable\n\t\t\t\t\t\t\t\t\tondblclick={() => startEdit(ri, ci, cell)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{#if editingCell?.row === ri && editingCell?.col === ci}\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\t\tclass=\"cell-edit\"\n\t\t\t\t\t\t\t\t\t\t\tbind:value={editValue}\n\t\t\t\t\t\t\t\t\t\t\tonblur={commitEdit}\n\t\t\t\t\t\t\t\t\t\t\tonkeydown={handleKeyDown}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\t\t<span class=\"cell-content\">{cell ?? ''}</span>\n\t\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t</tr>\n\t\t\t\t\t{/each}\n\t\t\t\t</tbody>\n\t\t\t</table>\n\t\t</div>\n\t\t<div class=\"table-footer\">\n\t\t\t<span>{value.data.length} rows × {value.headers.length} columns</span>\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No data</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-dataframe-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-dataframe-wrap.fullscreen {\n\t\tborder-radius: 0;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\theight: 100vh;\n\t}\n\n\t.gr-dataframe-wrap.fullscreen .table-container {\n\t\tflex: 1;\n\t\tmax-height: none !important;\n\t}\n\n\t.gr-dataframe-wrap.fullscreen table {\n\t\tfont-size: 14px;\n\t}\n\n\t.gr-dataframe-wrap.fullscreen th,\n\t.gr-dataframe-wrap.fullscreen td {\n\t\tpadding: 8px 12px;\n\t}\n\n\t.gr-dataframe-wrap.fullscreen .cell-content {\n\t\tmax-width: none;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.table-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.table-container {\n\t\toverflow: auto;\n\t\tmargin: 0 6px;\n\t}\n\n\ttable {\n\t\twidth: 100%;\n\t\tborder-collapse: collapse;\n\t\tfont-size: 11px;\n\t}\n\n\ttable.wrap td {\n\t\twhite-space: normal;\n\t\tword-break: break-word;\n\t}\n\n\ttable:not(.wrap) td {\n\t\twhite-space: nowrap;\n\t}\n\n\tth, td {\n\t\tpadding: 4px 8px;\n\t\ttext-align: left;\n\t\tborder-bottom: 1px solid var(--input-background-fill);\n\t}\n\n\tth {\n\t\tbackground: var(--background-fill-secondary);\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-weight: 500;\n\t\tfont-size: 10px;\n\t\tposition: sticky;\n\t\ttop: 0;\n\t\tz-index: 1;\n\t}\n\n\ttd {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\ttd.editable {\n\t\tcursor: pointer;\n\t}\n\n\ttd.editable:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 5%, transparent);\n\t}\n\n\t.row-num {\n\t\tcolor: var(--input-placeholder-color);\n\t\twidth: 40px;\n\t\ttext-align: center;\n\t\tfont-size: 10px;\n\t}\n\n\ttbody tr:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 2%, transparent);\n\t}\n\n\t.cell-content {\n\t\tdisplay: block;\n\t\tmax-width: 200px;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t}\n\n\t.cell-edit {\n\t\twidth: 100%;\n\t\tpadding: 2px 4px;\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: var(--background-fill-secondary);\n\t\tborder: 1px solid var(--color-accent);\n\t\tborder-radius: 2px;\n\t\toutline: none;\n\t}\n\n\t.table-footer {\n\t\tpadding: 4px 10px;\n\t\tfont-size: 10px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tborder-top: 1px solid var(--input-background-fill);\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Dialogue.svelte",
    "content": "<script lang=\"ts\">\n\tinterface DialogueLine {\n\t\tspeaker: string;\n\t\ttext: string;\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: DialogueLine[];\n\t\tspeakers?: string[];\n\t\teditable?: boolean;\n\t\tonchange?: (value: DialogueLine[]) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue = [], \n\t\tspeakers = [],\n\t\teditable = true, \n\t\tonchange \n\t}: Props = $props();\n\n\tlet containerEl: HTMLDivElement | null = $state(null);\n\tlet copySuccess = $state(false);\n\tlet isFullscreen = $state(false);\n\n\tconst baseColors = [\n\t\t'rgba(239, 68, 68, 0.15)',\n\t\t'rgba(59, 130, 246, 0.15)',\n\t\t'rgba(34, 197, 94, 0.15)',\n\t\t'rgba(168, 85, 247, 0.15)',\n\t\t'rgba(251, 191, 36, 0.15)',\n\t\t'rgba(236, 72, 153, 0.15)',\n\t\t'rgba(20, 184, 166, 0.15)',\n\t\t'rgba(99, 102, 241, 0.15)',\n\t];\n\n\tlet uniqueSpeakers = $derived.by(() => {\n\t\tconst fromValue = value.map(line => line.speaker);\n\t\tconst all = [...new Set([...speakers, ...fromValue])];\n\t\treturn all.filter(s => s && s.trim());\n\t});\n\n\tlet speakerColorMap = $derived.by(() => {\n\t\tconst map: Record<string, string> = {};\n\t\tuniqueSpeakers.forEach((speaker, idx) => {\n\t\t\tmap[speaker] = baseColors[idx % baseColors.length];\n\t\t});\n\t\treturn map;\n\t});\n\n\tfunction getSpeakerColor(speaker: string): string {\n\t\treturn speakerColorMap[speaker] || baseColors[0];\n\t}\n\n\tfunction copyDialogue() {\n\t\tconst text = value.map(line => `${line.speaker}: ${line.text}`).join('\\n');\n\t\tnavigator.clipboard.writeText(text).then(() => {\n\t\t\tcopySuccess = true;\n\t\t\tsetTimeout(() => copySuccess = false, 1500);\n\t\t});\n\t}\n\n\tfunction openFullscreen() {\n\t\tif (!containerEl) return;\n\t\tif (containerEl.requestFullscreen) {\n\t\t\tcontainerEl.requestFullscreen();\n\t\t} else if ((containerEl as any).webkitRequestFullscreen) {\n\t\t\t(containerEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction handleFullscreenChange() {\n\t\tisFullscreen = !!document.fullscreenElement;\n\t}\n\n\tfunction addLine(index: number) {\n\t\tconst newSpeaker = uniqueSpeakers.length > 0 ? uniqueSpeakers[0] : 'Speaker 1';\n\t\tconst newValue = [\n\t\t\t...value.slice(0, index + 1),\n\t\t\t{ speaker: newSpeaker, text: '' },\n\t\t\t...value.slice(index + 1)\n\t\t];\n\t\tonchange?.(newValue);\n\t}\n\n\tfunction removeLine(index: number) {\n\t\tif (value.length <= 1) return;\n\t\tconst newValue = [...value.slice(0, index), ...value.slice(index + 1)];\n\t\tonchange?.(newValue);\n\t}\n\n\tfunction updateSpeaker(index: number, speaker: string) {\n\t\tconst newValue = [...value];\n\t\tnewValue[index] = { ...newValue[index], speaker };\n\t\tonchange?.(newValue);\n\t}\n\n\tfunction updateText(index: number, text: string) {\n\t\tconst newValue = [...value];\n\t\tnewValue[index] = { ...newValue[index], text };\n\t\tonchange?.(newValue);\n\t}\n\n\tfunction handleSpeakerChange(e: Event, index: number) {\n\t\tconst select = e.target as HTMLSelectElement;\n\t\tupdateSpeaker(index, select.value);\n\t}\n\n\tfunction handleTextChange(e: Event, index: number) {\n\t\tconst textarea = e.target as HTMLTextAreaElement;\n\t\tupdateText(index, textarea.value);\n\t}\n\n\tlet availableSpeakers = $derived.by(() => {\n\t\tif (uniqueSpeakers.length > 0) return uniqueSpeakers;\n\t\treturn ['Speaker 1', 'Speaker 2', 'Speaker 3'];\n\t});\n</script>\n\n<svelte:document onfullscreenchange={handleFullscreenChange} />\n\n<div class=\"gr-dialogue-wrap\" class:fullscreen={isFullscreen} bind:this={containerEl}>\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"dialogue-actions\">\n\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t\t<button class=\"action-btn\" class:success={copySuccess} onclick={copyDialogue} title=\"Copy dialogue\">\n\t\t\t\t{#if copySuccess}\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polyline points=\"20 6 9 17 4 12\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t{:else}\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"/>\n\t\t\t\t\t\t<path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t{/if}\n\t\t\t</button>\n\t\t\t{#if editable}\n\t\t\t\t<button class=\"action-btn\" onclick={() => addLine(value.length - 1)} title=\"Add line\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/>\n\t\t\t\t\t\t<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\n\t<div class=\"dialogue-container\">\n\t\t{#if value.length === 0}\n\t\t\t<div class=\"gr-empty\">No dialogue</div>\n\t\t{:else}\n\t\t\t{#each value as line, i}\n\t\t\t\t<div class=\"dialogue-line\" style=\"--speaker-color: {getSpeakerColor(line.speaker)}\">\n\t\t\t\t\t<div class=\"speaker-wrapper\">\n\t\t\t\t\t\t{#if editable}\n\t\t\t\t\t\t\t<select \n\t\t\t\t\t\t\t\tclass=\"speaker-select\"\n\t\t\t\t\t\t\t\tvalue={line.speaker}\n\t\t\t\t\t\t\t\tonchange={(e) => handleSpeakerChange(e, i)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#each availableSpeakers as speaker}\n\t\t\t\t\t\t\t\t\t<option value={speaker}>{speaker}</option>\n\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t\t{#if !availableSpeakers.includes(line.speaker)}\n\t\t\t\t\t\t\t\t\t<option value={line.speaker}>{line.speaker}</option>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<span class=\"speaker-name\">{line.speaker}</span>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"text-wrapper\">\n\t\t\t\t\t\t{#if editable}\n\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\tclass=\"text-input\"\n\t\t\t\t\t\t\t\tvalue={line.text}\n\t\t\t\t\t\t\t\tplaceholder=\"Enter text...\"\n\t\t\t\t\t\t\t\toninput={(e) => handleTextChange(e, i)}\n\t\t\t\t\t\t\t\trows=\"1\"\n\t\t\t\t\t\t\t></textarea>\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<span class=\"text-content\">{line.text}</span>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t\t{#if editable && value.length > 1}\n\t\t\t\t\t\t<button class=\"remove-btn\" onclick={() => removeLine(i)} title=\"Remove line\">\n\t\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\t\t\t{/each}\n\t\t{/if}\n\t</div>\n</div>\n\n<style>\n\t.gr-dialogue-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-dialogue-wrap.fullscreen {\n\t\tborder-radius: 0;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\theight: 100vh;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.dialogue-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.action-btn.success svg {\n\t\tcolor: var(--primary-500, #22c55e);\n\t}\n\n\t.dialogue-container {\n\t\tpadding: 6px;\n\t\tmax-height: 200px;\n\t\toverflow-y: auto;\n\t}\n\n\t.gr-dialogue-wrap.fullscreen .dialogue-container {\n\t\tmax-height: none;\n\t\tflex: 1;\n\t\tpadding: 16px;\n\t}\n\n\t.dialogue-line {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 6px 8px;\n\t\tmargin-bottom: 3px;\n\t\tbackground: var(--speaker-color);\n\t\tborder-radius: 4px;\n\t\tmin-height: 28px;\n\t}\n\n\t.dialogue-line:last-child {\n\t\tmargin-bottom: 0;\n\t}\n\n\t.speaker-wrapper {\n\t\tflex-shrink: 0;\n\t\tmin-width: 50px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t}\n\n\t.speaker-select {\n\t\twidth: 100%;\n\t\tpadding: 2px 4px;\n\t\tfont-size: 10px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--button-primary-text-color);\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tborder-radius: 3px;\n\t\toutline: none;\n\t\tcursor: pointer;\n\t\ttransition: background 0.15s;\n\t\tappearance: none;\n\t\t-webkit-appearance: none;\n\t\t-moz-appearance: none;\n\t}\n\n\t.speaker-select:focus,\n\t.speaker-select:hover {\n\t\tbackground: rgba(0, 0, 0, 0.2);\n\t}\n\n\t.speaker-select option {\n\t\tbackground: var(--input-background-fill);\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.speaker-name {\n\t\tfont-size: 10px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t.text-wrapper {\n\t\tflex: 1;\n\t\tmin-width: 0;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t}\n\n\t.text-input {\n\t\twidth: 100%;\n\t\tpadding: 2px 6px;\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tborder-radius: 3px;\n\t\toutline: none;\n\t\tresize: none;\n\t\tfont-family: inherit;\n\t\tline-height: 1.4;\n\t\tmin-height: 20px;\n\t\tbox-sizing: border-box;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.text-input:focus,\n\t.text-input:hover {\n\t\tbackground: rgba(0, 0, 0, 0.15);\n\t}\n\n\t.text-input::placeholder {\n\t\tcolor: rgba(255, 255, 255, 0.3);\n\t}\n\n\t.text-content {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\tline-height: 1.4;\n\t\twhite-space: pre-wrap;\n\t\tword-break: break-word;\n\t}\n\n\t.remove-btn {\n\t\twidth: 16px;\n\t\theight: 16px;\n\t\tpadding: 2px;\n\t\tborder: none;\n\t\tbackground: transparent;\n\t\tborder-radius: 3px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\topacity: 0.3;\n\t\ttransition: opacity 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.remove-btn svg {\n\t\twidth: 10px;\n\t\theight: 10px;\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t.remove-btn:hover {\n\t\topacity: 1;\n\t}\n\n\t.remove-btn:hover svg {\n\t\tcolor: var(--error-text-color);\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n\n\t.gr-dialogue-wrap.fullscreen .dialogue-line {\n\t\tpadding: 12px 16px;\n\t\tmargin-bottom: 8px;\n\t}\n\n\t.gr-dialogue-wrap.fullscreen .speaker-wrapper {\n\t\tmin-width: 120px;\n\t}\n\n\t.gr-dialogue-wrap.fullscreen .speaker-select,\n\t.gr-dialogue-wrap.fullscreen .speaker-name {\n\t\tfont-size: 13px;\n\t\tpadding: 8px 12px;\n\t}\n\n\t.gr-dialogue-wrap.fullscreen .text-input,\n\t.gr-dialogue-wrap.fullscreen .text-content {\n\t\tfont-size: 14px;\n\t\tpadding: 8px 12px;\n\t\tmin-height: 38px;\n\t}\n</style>\n"
  },
  {
    "path": "daggr/frontend/src/components/Dropdown.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tchoices: [string, string | number][];\n\t\tvalue: string | number | null;\n\t\tdisabled?: boolean;\n\t\tfilterable?: boolean;\n\t\tallowCustomValue?: boolean;\n\t\tplaceholder?: string;\n\t\tonchange?: (value: string | number | null) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tchoices, \n\t\tvalue, \n\t\tdisabled = false, \n\t\tfilterable = true,\n\t\tallowCustomValue = false,\n\t\tplaceholder = 'Select...',\n\t\tonchange \n\t}: Props = $props();\n\n\tlet isOpen = $state(false);\n\tlet inputEl: HTMLInputElement | null = $state(null);\n\tlet inputWrapEl: HTMLDivElement | null = $state(null);\n\tlet filterText = $state('');\n\tlet dropdownPosition = $state({ top: 0, left: 0, width: 0 });\n\n\tlet choicesNames = $derived(choices.map(c => c[0]));\n\tlet choicesValues = $derived(choices.map(c => c[1]));\n\n\tlet displayText = $derived.by(() => {\n\t\tif (value === null || value === undefined) return '';\n\t\tconst idx = choicesValues.indexOf(value);\n\t\tif (idx !== -1) return choicesNames[idx];\n\t\tif (allowCustomValue) return String(value);\n\t\treturn '';\n\t});\n\n\tlet filteredChoices = $derived.by(() => {\n\t\tif (!filterText) return choices;\n\t\tconst lower = filterText.toLowerCase();\n\t\treturn choices.filter(([name]) => name.toLowerCase().includes(lower));\n\t});\n\n\tfunction updatePosition() {\n\t\tif (inputWrapEl) {\n\t\t\tconst rect = inputWrapEl.getBoundingClientRect();\n\t\t\tdropdownPosition = {\n\t\t\t\ttop: rect.bottom + 2,\n\t\t\t\tleft: rect.left,\n\t\t\t\twidth: rect.width\n\t\t\t};\n\t\t}\n\t}\n\n\tfunction handleSelect(internalValue: string | number) {\n\t\tonchange?.(internalValue);\n\t\tisOpen = false;\n\t\tfilterText = '';\n\t}\n\n\tfunction handleInputFocus() {\n\t\tif (!disabled) {\n\t\t\tupdatePosition();\n\t\t\tisOpen = true;\n\t\t\tfilterText = '';\n\t\t}\n\t}\n\n\tfunction handleInputBlur() {\n\t\tsetTimeout(() => {\n\t\t\tisOpen = false;\n\t\t\tif (allowCustomValue && filterText) {\n\t\t\t\tonchange?.(filterText);\n\t\t\t}\n\t\t\tfilterText = '';\n\t\t}, 150);\n\t}\n\n\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\tif (e.key === 'Escape') {\n\t\t\tisOpen = false;\n\t\t\tfilterText = '';\n\t\t\tinputEl?.blur();\n\t\t} else if (e.key === 'Enter' && filteredChoices.length > 0) {\n\t\t\thandleSelect(filteredChoices[0][1]);\n\t\t}\n\t}\n</script>\n\n<svelte:window onscroll={updatePosition} onresize={updatePosition} />\n\n<div class=\"gr-dropdown-wrap\">\n\t<span class=\"gr-label\">{label}</span>\n\t<div class=\"dropdown-container\">\n\t\t<div class=\"input-wrap\" bind:this={inputWrapEl}>\n\t\t\t<input\n\t\t\t\tbind:this={inputEl}\n\t\t\t\ttype=\"text\"\n\t\t\t\tclass=\"dropdown-input\"\n\t\t\t\tplaceholder={displayText || placeholder}\n\t\t\t\tvalue={isOpen ? filterText : displayText}\n\t\t\t\t{disabled}\n\t\t\t\treadonly={!filterable}\n\t\t\t\tonfocus={handleInputFocus}\n\t\t\t\tonblur={handleInputBlur}\n\t\t\t\toninput={(e) => filterText = (e.target as HTMLInputElement).value}\n\t\t\t\tonkeydown={handleKeyDown}\n\t\t\t/>\n\t\t\t<button class=\"dropdown-arrow\" class:open={isOpen} {disabled} tabindex=\"-1\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<polyline points=\"6 9 12 15 18 9\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t</div>\n\t</div>\n</div>\n\n{#if isOpen && filteredChoices.length > 0}\n\t<div \n\t\tclass=\"options-portal\"\n\t\tstyle=\"top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; width: {dropdownPosition.width}px;\"\n\t>\n\t\t{#each filteredChoices as [displayValue, internalValue]}\n\t\t\t<button\n\t\t\t\tclass=\"option\"\n\t\t\t\tclass:selected={value === internalValue}\n\t\t\t\tonmousedown={(e) => { e.preventDefault(); handleSelect(internalValue); }}\n\t\t\t>\n\t\t\t\t{displayValue}\n\t\t\t</button>\n\t\t{/each}\n\t</div>\n{/if}\n\n<style>\n\t.gr-dropdown-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: visible;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 0;\n\t}\n\n\t.dropdown-container {\n\t\tposition: relative;\n\t\tpadding: 4px 10px 8px;\n\t}\n\n\t.input-wrap {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\ttransition: border-color 0.15s;\n\t}\n\n\t.input-wrap:focus-within {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.dropdown-input {\n\t\tflex: 1;\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tpadding: 6px 8px;\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\toutline: none;\n\t\tmin-width: 0;\n\t}\n\n\t.dropdown-input::placeholder {\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.dropdown-input:disabled {\n\t\topacity: 0.6;\n\t\tcursor: not-allowed;\n\t}\n\n\t.dropdown-arrow {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t\tpadding: 4px;\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tcursor: pointer;\n\t\tcolor: var(--neutral-500);\n\t\ttransition: transform 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.dropdown-arrow:disabled {\n\t\tcursor: not-allowed;\n\t}\n\n\t.dropdown-arrow.open {\n\t\ttransform: rotate(180deg);\n\t}\n\n\t.dropdown-arrow svg {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t}\n\n\t.options-portal {\n\t\tposition: fixed;\n\t\tbackground: var(--background-fill-secondary);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 4px;\n\t\tmax-height: 150px;\n\t\toverflow-y: auto;\n\t\tz-index: 10000;\n\t\tbox-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n\t}\n\n\t.option {\n\t\tdisplay: block;\n\t\twidth: 100%;\n\t\tpadding: 6px 10px;\n\t\ttext-align: left;\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\tcursor: pointer;\n\t\ttransition: background 0.1s;\n\t}\n\n\t.option:hover {\n\t\tbackground: var(--background-fill-primary);\n\t}\n\n\t.option.selected {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t\tcolor: var(--color-accent);\n\t}\n</style>\n"
  },
  {
    "path": "daggr/frontend/src/components/EmbeddedComponent.svelte",
    "content": "<script lang=\"ts\">\n\timport Audio from './Audio.svelte';\n\timport Textbox from './Textbox.svelte';\n\timport Image from './Image.svelte';\n\timport Dialogue from './Dialogue.svelte';\n\timport Video from './Video.svelte';\n\timport File from './File.svelte';\n\timport Dataframe from './Dataframe.svelte';\n\timport Gallery from './Gallery.svelte';\n\timport Code from './Code.svelte';\n\timport Json from './Json.svelte';\n\timport Slider from './Slider.svelte';\n\timport Radio from './Radio.svelte';\n\timport Dropdown from './Dropdown.svelte';\n\timport CheckboxGroup from './CheckboxGroup.svelte';\n\timport ColorPicker from './ColorPicker.svelte';\n\timport Label from './Label.svelte';\n\timport HighlightedText from './HighlightedText.svelte';\n\timport Markdown from './Markdown.svelte';\n\timport Model3D from './Model3D.svelte';\n\timport Html from './Html.svelte';\n\timport type { GradioComponentData } from '../types';\n\n\tinterface Props {\n\t\tcomp: GradioComponentData;\n\t\tnodeId: string;\n\t\tisInputNode: boolean;\n\t\tvalue: any;\n\t\tonchange?: (portName: string, value: any) => void;\n\t}\n\n\tlet { comp, nodeId, isInputNode, value, onchange }: Props = $props();\n\n\tfunction handleNumberInput(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tonchange?.(comp.port_name, parseFloat(target.value));\n\t}\n\n\tfunction handleCheckboxChange(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tonchange?.(comp.port_name, target.checked);\n\t}\n</script>\n\n<div class=\"embedded-component\">\n\t{#if comp.component === 'textbox' || comp.component === 'text'}\n\t\t<Textbox\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tplaceholder={comp.props?.placeholder || ''}\n\t\t\tlines={comp.props?.lines || 1}\n\t\t\tdisabled={!isInputNode}\n\t\t\t{value}\n\t\t\toninput={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'number'}\n\t\t<div class=\"gr-textbox-wrap\">\n\t\t\t<span class=\"gr-label\">{comp.props?.label || comp.port_name}</span>\n\t\t\t<input\n\t\t\t\ttype=\"number\"\n\t\t\t\tclass=\"gr-input\"\n\t\t\t\tdisabled={!isInputNode}\n\t\t\t\t{value}\n\t\t\t\tmin={comp.props?.minimum}\n\t\t\t\tmax={comp.props?.maximum}\n\t\t\t\tstep={comp.props?.step}\n\t\t\t\toninput={handleNumberInput}\n\t\t\t/>\n\t\t</div>\n\t{:else if comp.component === 'checkbox'}\n\t\t<label class=\"gr-checkbox-wrap\">\n\t\t\t<input\n\t\t\t\ttype=\"checkbox\"\n\t\t\t\tdisabled={!isInputNode}\n\t\t\t\tchecked={value}\n\t\t\t\tonchange={handleCheckboxChange}\n\t\t\t/>\n\t\t\t<span class=\"gr-check-label\">{comp.props?.label || comp.port_name}</span>\n\t\t</label>\n\t{:else if comp.component === 'markdown'}\n\t\t<Markdown\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value || ''}\n\t\t\tshowLabel={true}\n\t\t/>\n\t{:else if comp.component === 'html'}\n\t\t<Html\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value || ''}\n\t\t\tshowLabel={true}\n\t\t/>\n\t{:else if comp.component === 'json'}\n\t\t<Json\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t\topen={comp.props?.open ?? 2}\n\t\t\teditable={isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'audio'}\n\t\t<Audio\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t\tid=\"{nodeId}_{comp.port_name}\"\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'image'}\n\t\t<Image\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t\teditable={isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'video'}\n\t\t<Video\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t\teditable={isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'file'}\n\t\t<File\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t\teditable={isInputNode}\n\t\t\tfileTypes={comp.props?.file_types}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'dataframe'}\n\t\t<Dataframe\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t\teditable={isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'gallery'}\n\t\t<Gallery\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t/>\n\t{:else if comp.component === 'dialogue'}\n\t\t<Dialogue\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={Array.isArray(value) ? value : (value ? [value] : [])}\n\t\t\tspeakers={comp.props?.speakers || []}\n\t\t\teditable={true}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'code'}\n\t\t<Code\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value || ''}\n\t\t\tlanguage={comp.props?.language || 'text'}\n\t\t\teditable={isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'slider'}\n\t\t<Slider\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value ?? comp.props?.value ?? 0}\n\t\t\tmin={comp.props?.minimum ?? 0}\n\t\t\tmax={comp.props?.maximum ?? 100}\n\t\t\tstep={comp.props?.step ?? 1}\n\t\t\tdisabled={!isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'radio'}\n\t\t<Radio\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tchoices={comp.props?.choices || []}\n\t\t\tvalue={value}\n\t\t\tdisabled={!isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'dropdown'}\n\t\t<Dropdown\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tchoices={comp.props?.choices || []}\n\t\t\tvalue={value}\n\t\t\tdisabled={!isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'checkboxgroup'}\n\t\t<CheckboxGroup\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tchoices={comp.props?.choices || []}\n\t\t\tvalue={value || []}\n\t\t\tdisabled={!isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'colorpicker'}\n\t\t<ColorPicker\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value || '#000000'}\n\t\t\tdisabled={!isInputNode}\n\t\t\tonchange={(v) => onchange?.(comp.port_name, v)}\n\t\t/>\n\t{:else if comp.component === 'label'}\n\t\t<Label\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t/>\n\t{:else if comp.component === 'highlightedtext'}\n\t\t<HighlightedText\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t/>\n\t{:else if comp.component === 'model3d'}\n\t\t<Model3D\n\t\t\tlabel={comp.props?.label || comp.port_name}\n\t\t\tvalue={value}\n\t\t/>\n\t{:else}\n\t\t<div class=\"gr-fallback\">\n\t\t\t<span class=\"fallback-type\">{comp.component}</span>\n\t\t\t{#if comp.value}\n\t\t\t\t<pre>{typeof comp.value === 'string' ? comp.value : JSON.stringify(comp.value, null, 2)}</pre>\n\t\t\t{/if}\n\t\t</div>\n\t{/if}\n</div>\n\n<style>\n\t.embedded-component {\n\t\tmargin-bottom: 8px;\n\t}\n\n\t.embedded-component:last-child {\n\t\tmargin-bottom: 0;\n\t}\n\n\t.gr-textbox-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 0;\n\t}\n\n\t.gr-input {\n\t\twidth: 100%;\n\t\tpadding: 4px 10px 8px;\n\t\tfont-size: 11px;\n\t\tfont-family: inherit;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\toutline: none;\n\t\tbox-sizing: border-box;\n\t}\n\n\t.gr-input::placeholder {\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.gr-textbox-wrap:focus-within {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.gr-input:disabled {\n\t\topacity: 0.7;\n\t\tcursor: not-allowed;\n\t}\n\n\t.gr-checkbox-wrap {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tcursor: pointer;\n\t\tpadding: 6px 0;\n\t}\n\n\t.gr-checkbox-wrap input[type=\"checkbox\"] {\n\t\twidth: 14px;\n\t\theight: 14px;\n\t\taccent-color: var(--color-accent);\n\t\tcursor: pointer;\n\t}\n\n\t.gr-check-label {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.gr-fallback {\n\t\tfont-size: 10px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tpadding: 8px 10px;\n\t\tborder-radius: 6px;\n\t}\n\n\t.gr-fallback .fallback-type {\n\t\tdisplay: inline-block;\n\t\tcolor: var(--neutral-500);\n\t\tfont-style: italic;\n\t\tfont-size: 9px;\n\t\tbackground: var(--background-fill-secondary);\n\t\tpadding: 2px 6px;\n\t\tborder-radius: 4px;\n\t\tmargin-bottom: 4px;\n\t}\n\n\t.gr-fallback pre {\n\t\tmargin: 0;\n\t\tfont-size: 9px;\n\t\twhite-space: pre-wrap;\n\t\tword-break: break-all;\n\t\tmax-height: 60px;\n\t\toverflow: auto;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/File.svelte",
    "content": "<script lang=\"ts\">\n\tinterface FileValue {\n\t\tname: string;\n\t\tsize: number;\n\t\turl?: string;\n\t\tdata?: File | Blob;\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: FileValue | FileValue[] | null;\n\t\tfileTypes?: string[];\n\t\tmultiple?: boolean;\n\t\teditable?: boolean;\n\t\tonchange?: (value: FileValue | FileValue[] | null) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tfileTypes, \n\t\tmultiple = false, \n\t\teditable = true, \n\t\tonchange \n\t}: Props = $props();\n\n\tlet fileInputEl: HTMLInputElement | null = $state(null);\n\tlet isDragging = $state(false);\n\n\tfunction normalizeFileValue(v: any): FileValue {\n\t\tif (typeof v === 'string') {\n\t\t\tconst name = v.split('/').pop() || 'file';\n\t\t\treturn { name, size: 0, url: v };\n\t\t}\n\t\treturn v;\n\t}\n\n\tlet files = $derived.by(() => {\n\t\tif (!value) return [];\n\t\tconst arr = Array.isArray(value) ? value : [value];\n\t\treturn arr.map(normalizeFileValue);\n\t});\n\n\tlet acceptStr = $derived(fileTypes?.join(',') || '*');\n\n\tfunction triggerUpload() {\n\t\tfileInputEl?.click();\n\t}\n\n\tfunction handleFileSelect(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst selectedFiles = target.files;\n\t\tif (selectedFiles && selectedFiles.length > 0) {\n\t\t\tprocessFiles(Array.from(selectedFiles));\n\t\t}\n\t\ttarget.value = '';\n\t}\n\n\tfunction processFiles(fileList: File[]) {\n\t\tconst newFiles: FileValue[] = fileList.map(f => ({\n\t\t\tname: f.name,\n\t\t\tsize: f.size,\n\t\t\turl: URL.createObjectURL(f),\n\t\t\tdata: f\n\t\t}));\n\n\t\tif (multiple) {\n\t\t\tonchange?.([...files, ...newFiles]);\n\t\t} else {\n\t\t\tonchange?.(newFiles[0]);\n\t\t}\n\t}\n\n\tfunction removeFile(index: number) {\n\t\tif (multiple) {\n\t\t\tconst newFiles = files.filter((_, i) => i !== index);\n\t\t\tonchange?.(newFiles.length > 0 ? newFiles : null);\n\t\t} else {\n\t\t\tonchange?.(null);\n\t\t}\n\t}\n\n\tfunction clearAll() {\n\t\tonchange?.(null);\n\t}\n\n\tfunction formatSize(bytes: number): string {\n\t\tif (!bytes) return '';\n\t\tif (bytes < 1024) return `${bytes} B`;\n\t\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n\t\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n\t}\n\n\tfunction handleDragOver(e: DragEvent) {\n\t\te.preventDefault();\n\t\tif (editable) isDragging = true;\n\t}\n\n\tfunction handleDragLeave() {\n\t\tisDragging = false;\n\t}\n\n\tfunction handleDrop(e: DragEvent) {\n\t\te.preventDefault();\n\t\tisDragging = false;\n\t\tif (!editable) return;\n\n\t\tconst droppedFiles = e.dataTransfer?.files;\n\t\tif (droppedFiles && droppedFiles.length > 0) {\n\t\t\tprocessFiles(Array.from(droppedFiles));\n\t\t}\n\t}\n\n\tasync function downloadFile(file: FileValue) {\n\t\tconst url = file.url || (file.data ? URL.createObjectURL(file.data) : null);\n\t\tif (!url) return;\n\n\t\tconst link = document.createElement('a');\n\t\tlink.href = url;\n\t\tlink.download = file.name;\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n</script>\n\n<div \n\tclass=\"gr-file-wrap\"\n\tclass:dragging={isDragging}\n\tondragover={handleDragOver}\n\tondragleave={handleDragLeave}\n\tondrop={handleDrop}\n>\n\t<input\n\t\tbind:this={fileInputEl}\n\t\ttype=\"file\"\n\t\taccept={acceptStr}\n\t\t{multiple}\n\t\tstyle=\"display: none\"\n\t\tonchange={handleFileSelect}\n\t/>\n\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"file-actions\">\n\t\t\t{#if editable}\n\t\t\t\t<button class=\"action-btn\" onclick={triggerUpload} title=\"Upload file\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t{#if files.length > 0}\n\t\t\t\t\t<button class=\"action-btn\" onclick={clearAll} title=\"Clear all\">\n\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t{/if}\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\n\t{#if files.length > 0}\n\t\t<div class=\"file-list\">\n\t\t\t{#each files as file, index}\n\t\t\t\t<div class=\"file-item\">\n\t\t\t\t\t<div class=\"file-icon\">\n\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/>\n\t\t\t\t\t\t\t<polyline points=\"14 2 14 8 20 8\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"file-info\">\n\t\t\t\t\t\t<span class=\"file-name\">{file.name}</span>\n\t\t\t\t\t\t<span class=\"file-size\">{formatSize(file.size)}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"file-item-actions\">\n\t\t\t\t\t\t<button class=\"item-btn\" onclick={() => downloadFile(file)} title=\"Download\">\n\t\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t\t\t<polyline points=\"7 10 12 15 17 10\"/>\n\t\t\t\t\t\t\t\t<line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{#if editable}\n\t\t\t\t\t\t\t<button class=\"item-btn\" onclick={() => removeFile(index)} title=\"Remove\">\n\t\t\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t{/each}\n\t\t</div>\n\t{:else if editable}\n\t\t<div class=\"drop-zone\">\n\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t</svg>\n\t\t\t<span>Drop files here or click to upload</span>\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No file</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-file-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t\ttransition: border-color 0.15s;\n\t}\n\n\t.gr-file-wrap.dragging {\n\t\tborder-color: var(--color-accent);\n\t\tborder-style: dashed;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.file-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.file-list {\n\t\tpadding: 0 6px 6px;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 4px;\n\t}\n\n\t.file-item {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 6px 8px;\n\t\tbackground: var(--input-background-fill);\n\t\tborder-radius: 4px;\n\t}\n\n\t.file-icon {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t\tcolor: var(--neutral-500);\n\t\tflex-shrink: 0;\n\t}\n\n\t.file-icon svg {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t}\n\n\t.file-info {\n\t\tflex: 1;\n\t\tmin-width: 0;\n\t}\n\n\t.file-name {\n\t\tdisplay: block;\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\twhite-space: nowrap;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t}\n\n\t.file-size {\n\t\tfont-size: 10px;\n\t\tcolor: var(--neutral-500);\n\t}\n\n\t.file-item-actions {\n\t\tdisplay: flex;\n\t\tgap: 2px;\n\t}\n\n\t.item-btn {\n\t\twidth: 18px;\n\t\theight: 18px;\n\t\tpadding: 2px;\n\t\tborder: none;\n\t\tbackground: transparent;\n\t\tborder-radius: 3px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.item-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--neutral-500);\n\t}\n\n\t.item-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 10%, transparent);\n\t}\n\n\t.item-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.drop-zone {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tgap: 8px;\n\t\tpadding: 20px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tcursor: pointer;\n\t}\n\n\t.drop-zone svg {\n\t\twidth: 32px;\n\t\theight: 32px;\n\t}\n\n\t.drop-zone span {\n\t\tfont-size: 11px;\n\t}\n\n\t.drop-zone:hover {\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Gallery.svelte",
    "content": "<script lang=\"ts\">\n\tinterface GalleryItem {\n\t\timage?: { url: string };\n\t\tvideo?: { url: string };\n\t\tcaption?: string | null;\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: GalleryItem[] | null;\n\t\tcolumns?: number;\n\t\theight?: number;\n\t\tobjectFit?: 'contain' | 'cover' | 'fill';\n\t\tallowPreview?: boolean;\n\t\tonselect?: (index: number, item: GalleryItem) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tcolumns = 3, \n\t\theight = 200,\n\t\tobjectFit = 'cover',\n\t\tallowPreview = true,\n\t\tonselect \n\t}: Props = $props();\n\n\tlet selectedIndex: number | null = $state(null);\n\tlet showPreview = $state(false);\n\n\tlet items = $derived(value || []);\n\n\tfunction handleItemClick(index: number) {\n\t\tselectedIndex = index;\n\t\tonselect?.(index, items[index]);\n\t\tif (allowPreview) {\n\t\t\tshowPreview = true;\n\t\t}\n\t}\n\n\tfunction closePreview() {\n\t\tshowPreview = false;\n\t}\n\n\tfunction navigatePreview(delta: number) {\n\t\tif (selectedIndex === null) return;\n\t\tconst newIndex = (selectedIndex + delta + items.length) % items.length;\n\t\tselectedIndex = newIndex;\n\t\tonselect?.(newIndex, items[newIndex]);\n\t}\n\n\tfunction handleKeyDown(e: KeyboardEvent) {\n\t\tif (!showPreview) return;\n\t\tif (e.key === 'Escape') closePreview();\n\t\tif (e.key === 'ArrowLeft') navigatePreview(-1);\n\t\tif (e.key === 'ArrowRight') navigatePreview(1);\n\t}\n</script>\n\n<svelte:window onkeydown={handleKeyDown} />\n\n<div class=\"gr-gallery-wrap\">\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t{#if items.length > 0}\n\t\t\t<span class=\"item-count\">{items.length} items</span>\n\t\t{/if}\n\t</div>\n\n\t{#if items.length > 0}\n\t\t<div \n\t\t\tclass=\"gallery-grid\" \n\t\t\tstyle:grid-template-columns=\"repeat({columns}, 1fr)\"\n\t\t\tstyle:max-height=\"{height}px\"\n\t\t>\n\t\t\t{#each items as item, index}\n\t\t\t\t<button\n\t\t\t\t\tclass=\"gallery-item\"\n\t\t\t\t\tclass:selected={selectedIndex === index}\n\t\t\t\t\tonclick={() => handleItemClick(index)}\n\t\t\t\t>\n\t\t\t\t\t{#if item.image?.url}\n\t\t\t\t\t\t<img src={item.image.url} alt={item.caption || `Image ${index + 1}`} style:object-fit={objectFit} />\n\t\t\t\t\t{:else if item.video?.url}\n\t\t\t\t\t\t<!-- svelte-ignore a11y_media_has_caption -->\n\t\t\t\t\t\t<video src={item.video.url} style:object-fit={objectFit}></video>\n\t\t\t\t\t\t<div class=\"video-badge\">\n\t\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n\t\t\t\t\t\t\t\t<polygon points=\"5 3 19 12 5 21 5 3\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t{/if}\n\t\t\t\t\t{#if item.caption}\n\t\t\t\t\t\t<div class=\"caption\">{item.caption}</div>\n\t\t\t\t\t{/if}\n\t\t\t\t</button>\n\t\t\t{/each}\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No images</div>\n\t{/if}\n</div>\n\n{#if showPreview && selectedIndex !== null && items[selectedIndex]}\n\t<div class=\"preview-overlay\" onclick={closePreview}>\n\t\t<div class=\"preview-content\" onclick={(e) => e.stopPropagation()}>\n\t\t\t<button class=\"preview-close\" onclick={closePreview}>\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t\t\n\t\t\t{#if items.length > 1}\n\t\t\t\t<button class=\"preview-nav prev\" onclick={() => navigatePreview(-1)}>\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polyline points=\"15 18 9 12 15 6\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"preview-nav next\" onclick={() => navigatePreview(1)}>\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polyline points=\"9 18 15 12 9 6\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\n\t\t\t{#if items[selectedIndex].image?.url}\n\t\t\t\t<img src={items[selectedIndex].image?.url} alt={items[selectedIndex].caption || ''} />\n\t\t\t{:else if items[selectedIndex].video?.url}\n\t\t\t\t<!-- svelte-ignore a11y_media_has_caption -->\n\t\t\t\t<video src={items[selectedIndex].video?.url} controls autoplay></video>\n\t\t\t{/if}\n\n\t\t\t{#if items[selectedIndex].caption}\n\t\t\t\t<div class=\"preview-caption\">{items[selectedIndex].caption}</div>\n\t\t\t{/if}\n\n\t\t\t<div class=\"preview-counter\">{selectedIndex + 1} / {items.length}</div>\n\t\t</div>\n\t</div>\n{/if}\n\n<style>\n\t.gr-gallery-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.item-count {\n\t\tfont-size: 10px;\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.gallery-grid {\n\t\tdisplay: grid;\n\t\tgap: 4px;\n\t\tpadding: 0 6px 6px;\n\t\toverflow-y: auto;\n\t\tscrollbar-width: thin;\n\t\tscrollbar-color: rgba(136, 136, 136, 0.4) transparent;\n\t}\n\n\t.gallery-grid::-webkit-scrollbar {\n\t\twidth: 6px;\n\t\theight: 6px;\n\t}\n\n\t.gallery-grid::-webkit-scrollbar-track {\n\t\tbackground: transparent;\n\t}\n\n\t.gallery-grid::-webkit-scrollbar-thumb {\n\t\tbackground-color: rgba(136, 136, 136, 0.3);\n\t\tborder-radius: 10px;\n\t\tborder: 1px solid transparent;\n\t\tbackground-clip: content-box;\n\t}\n\n\t.gallery-grid::-webkit-scrollbar-thumb:hover {\n\t\tbackground-color: var(--color-accent);\n\t}\n\n\t.gallery-item {\n\t\tposition: relative;\n\t\taspect-ratio: 1;\n\t\tbackground: var(--background-fill-secondary);\n\t\tborder: 2px solid transparent;\n\t\tborder-radius: 4px;\n\t\toverflow: hidden;\n\t\tcursor: pointer;\n\t\tpadding: 0;\n\t\ttransition: border-color 0.15s;\n\t}\n\n\t.gallery-item:hover {\n\t\tborder-color: var(--border-color-primary);\n\t}\n\n\t.gallery-item.selected {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.gallery-item img,\n\t.gallery-item video {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t}\n\n\t.video-badge {\n\t\tposition: absolute;\n\t\ttop: 4px;\n\t\tright: 4px;\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tbackground: rgba(0, 0, 0, 0.6);\n\t\tborder-radius: 50%;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t}\n\n\t.video-badge svg {\n\t\twidth: 10px;\n\t\theight: 10px;\n\t\tcolor: white;\n\t\tmargin-left: 2px;\n\t}\n\n\t.caption {\n\t\tposition: absolute;\n\t\tbottom: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tpadding: 4px 6px;\n\t\tbackground: linear-gradient(transparent, rgba(0, 0, 0, 0.8));\n\t\tfont-size: 10px;\n\t\tcolor: white;\n\t\twhite-space: nowrap;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n\n\t.preview-overlay {\n\t\tposition: fixed;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\tbackground: rgba(0, 0, 0, 0.9);\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tz-index: 1000;\n\t}\n\n\t.preview-content {\n\t\tposition: relative;\n\t\tmax-width: 90vw;\n\t\tmax-height: 90vh;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t}\n\n\t.preview-content img,\n\t.preview-content video {\n\t\tmax-width: 100%;\n\t\tmax-height: 80vh;\n\t\tborder-radius: 4px;\n\t}\n\n\t.preview-close {\n\t\tposition: absolute;\n\t\ttop: -40px;\n\t\tright: 0;\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tbackground: rgba(255, 255, 255, 0.1);\n\t\tborder: none;\n\t\tborder-radius: 50%;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.preview-close svg {\n\t\twidth: 18px;\n\t\theight: 18px;\n\t\tcolor: white;\n\t}\n\n\t.preview-close:hover {\n\t\tbackground: rgba(255, 255, 255, 0.2);\n\t}\n\n\t.preview-nav {\n\t\tposition: absolute;\n\t\ttop: 50%;\n\t\ttransform: translateY(-50%);\n\t\twidth: 40px;\n\t\theight: 40px;\n\t\tbackground: rgba(255, 255, 255, 0.1);\n\t\tborder: none;\n\t\tborder-radius: 50%;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.preview-nav.prev {\n\t\tleft: -60px;\n\t}\n\n\t.preview-nav.next {\n\t\tright: -60px;\n\t}\n\n\t.preview-nav svg {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t\tcolor: white;\n\t}\n\n\t.preview-nav:hover {\n\t\tbackground: rgba(255, 255, 255, 0.2);\n\t}\n\n\t.preview-caption {\n\t\tmargin-top: 12px;\n\t\tfont-size: 13px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\ttext-align: center;\n\t}\n\n\t.preview-counter {\n\t\tposition: absolute;\n\t\tbottom: -30px;\n\t\tfont-size: 12px;\n\t\tcolor: var(--neutral-500);\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/HighlightedText.svelte",
    "content": "<script lang=\"ts\">\n\tinterface TextSpan {\n\t\ttext: string;\n\t\tlabel?: string | null;\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: TextSpan[];\n\t\tcolorMap?: Record<string, string>;\n\t\tshowLegend?: boolean;\n\t\tshowInlineCategory?: boolean;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tcolorMap = {},\n\t\tshowLegend = true,\n\t\tshowInlineCategory = true\n\t}: Props = $props();\n\n\tconst defaultColors = [\n\t\t'#f97316', '#22c55e', '#3b82f6', '#a855f7', '#ec4899',\n\t\t'#eab308', '#14b8a6', '#ef4444', '#8b5cf6', '#06b6d4'\n\t];\n\n\tlet allLabels = $derived.by(() => {\n\t\tconst labels = new Set<string>();\n\t\tvalue.forEach(span => {\n\t\t\tif (span.label) labels.add(span.label);\n\t\t});\n\t\treturn Array.from(labels);\n\t});\n\n\tfunction getColor(labelName: string): string {\n\t\tif (colorMap[labelName]) return colorMap[labelName];\n\t\tconst idx = allLabels.indexOf(labelName);\n\t\treturn defaultColors[idx % defaultColors.length];\n\t}\n\n\tfunction getBackgroundColor(labelName: string): string {\n\t\tconst color = getColor(labelName);\n\t\treturn color + '33';\n\t}\n</script>\n\n<div class=\"gr-highlightedtext-wrap\">\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t</div>\n\n\t{#if value && value.length > 0}\n\t\t{#if showLegend && allLabels.length > 0}\n\t\t\t<div class=\"legend\">\n\t\t\t\t{#each allLabels as labelName}\n\t\t\t\t\t<div class=\"legend-item\">\n\t\t\t\t\t\t<span class=\"legend-color\" style:background-color={getColor(labelName)}></span>\n\t\t\t\t\t\t<span class=\"legend-text\">{labelName}</span>\n\t\t\t\t\t</div>\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<div class=\"text-content\">\n\t\t\t{#each value as span}\n\t\t\t\t{#if span.label}\n\t\t\t\t\t<span \n\t\t\t\t\t\tclass=\"highlighted-span\"\n\t\t\t\t\t\tstyle:background-color={getBackgroundColor(span.label)}\n\t\t\t\t\t\tstyle:border-color={getColor(span.label)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{span.text}\n\t\t\t\t\t\t{#if showInlineCategory}\n\t\t\t\t\t\t\t<span class=\"inline-label\" style:background-color={getColor(span.label)}>{span.label}</span>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</span>\n\t\t\t\t{:else}\n\t\t\t\t\t<span class=\"plain-text\">{span.text}</span>\n\t\t\t\t{/if}\n\t\t\t{/each}\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No text</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-highlightedtext-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.legend {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 8px;\n\t\tpadding: 0 10px 8px;\n\t}\n\n\t.legend-item {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 4px;\n\t}\n\n\t.legend-color {\n\t\twidth: 10px;\n\t\theight: 10px;\n\t\tborder-radius: 2px;\n\t}\n\n\t.legend-text {\n\t\tfont-size: 10px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.text-content {\n\t\tpadding: 0 10px 10px;\n\t\tfont-size: 12px;\n\t\tline-height: 1.8;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.plain-text {\n\t\twhite-space: pre-wrap;\n\t}\n\n\t.highlighted-span {\n\t\tdisplay: inline;\n\t\tpadding: 2px 4px;\n\t\tborder-radius: 3px;\n\t\tborder-bottom: 2px solid;\n\t\tposition: relative;\n\t}\n\n\t.inline-label {\n\t\tfont-size: 9px;\n\t\tpadding: 1px 4px;\n\t\tborder-radius: 2px;\n\t\tcolor: white;\n\t\tmargin-left: 4px;\n\t\tvertical-align: middle;\n\t\tfont-weight: 500;\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px 10px;\n\t\ttext-align: center;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Html.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel?: string;\n\t\tvalue: string;\n\t\tshowLabel?: boolean;\n\t}\n\n\tlet { \n\t\tlabel = '', \n\t\tvalue, \n\t\tshowLabel = false\n\t}: Props = $props();\n\n\tlet contentEl: HTMLDivElement | null = $state(null);\n\n\tfunction openFullscreen() {\n\t\tif (!contentEl) return;\n\t\tif (contentEl.requestFullscreen) {\n\t\t\tcontentEl.requestFullscreen();\n\t\t} else if ((contentEl as any).webkitRequestFullscreen) {\n\t\t\t(contentEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n</script>\n\n<div class=\"gr-html-wrap\">\n\t<div class=\"gr-header\">\n\t\t{#if showLabel && label}\n\t\t\t<span class=\"gr-label\">{label}</span>\n\t\t{:else}\n\t\t\t<span class=\"gr-label-spacer\"></span>\n\t\t{/if}\n\t\t{#if value}\n\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t{/if}\n\t</div>\n\t\n\t<div class=\"html-content\" bind:this={contentEl}>\n\t\t{@html value}\n\t</div>\n</div>\n\n<style>\n\t.gr-html-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px 6px 0 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.gr-label-spacer {\n\t\tflex: 1;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.html-content {\n\t\tpadding: 8px 10px 10px;\n\t\tfont-size: 12px;\n\t\tline-height: 1.5;\n\t\tcolor: var(--body-text-color);\n\t\tmax-height: 200px;\n\t\toverflow: auto;\n\t}\n\n\t.html-content:fullscreen {\n\t\tbackground: var(--block-background-fill);\n\t\tpadding: 40px;\n\t\tmax-height: none;\n\t\toverflow: auto;\n\t}\n</style>\n"
  },
  {
    "path": "daggr/frontend/src/components/Image.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: any;\n\t\teditable?: boolean;\n\t\tonchange?: (value: any) => void;\n\t}\n\n\tlet { label, value, editable = true, onchange }: Props = $props();\n\n\tlet imgEl: HTMLImageElement | null = $state(null);\n\tlet fileInputEl: HTMLInputElement | null = $state(null);\n\tlet showWebcam = $state(false);\n\tlet videoEl: HTMLVideoElement | null = $state(null);\n\tlet stream: MediaStream | null = $state(null);\n\n\tlet src = $derived.by(() => {\n\t\tif (!value) return null;\n\t\tif (typeof value === 'string') return value;\n\t\tif (value.url) return value.url;\n\t\tif (value instanceof Blob) return URL.createObjectURL(value);\n\t\treturn null;\n\t});\n\n\tasync function downloadImage() {\n\t\tif (!src) return;\n\t\ttry {\n\t\t\tconst response = await fetch(src);\n\t\t\tconst blob = await response.blob();\n\t\t\tconst blobUrl = URL.createObjectURL(blob);\n\t\t\tconst link = document.createElement('a');\n\t\t\tlink.href = blobUrl;\n\t\t\tconst ext = blob.type.split('/')[1] || 'png';\n\t\t\tlink.download = `${label || 'image'}.${ext}`;\n\t\t\tdocument.body.appendChild(link);\n\t\t\tlink.click();\n\t\t\tdocument.body.removeChild(link);\n\t\t\tURL.revokeObjectURL(blobUrl);\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to download image:', e);\n\t\t}\n\t}\n\n\tfunction openFullscreen() {\n\t\tif (!imgEl) return;\n\t\tif (imgEl.requestFullscreen) {\n\t\t\timgEl.requestFullscreen();\n\t\t} else if ((imgEl as any).webkitRequestFullscreen) {\n\t\t\t(imgEl as any).webkitRequestFullscreen();\n\t\t} else if ((imgEl as any).msRequestFullscreen) {\n\t\t\t(imgEl as any).msRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction triggerUpload() {\n\t\tfileInputEl?.click();\n\t}\n\n\tfunction handleFileSelect(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst file = target.files?.[0];\n\t\tif (file) {\n\t\t\tonchange?.(file);\n\t\t}\n\t\ttarget.value = '';\n\t}\n\n\tasync function startWebcam() {\n\t\ttry {\n\t\t\tstream = await navigator.mediaDevices.getUserMedia({ video: true });\n\t\t\tshowWebcam = true;\n\t\t\tawait new Promise(resolve => setTimeout(resolve, 50));\n\t\t\tif (videoEl && stream) {\n\t\t\t\tvideoEl.srcObject = stream;\n\t\t\t\tvideoEl.play();\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to access webcam:', e);\n\t\t}\n\t}\n\n\tfunction captureFromWebcam() {\n\t\tif (!videoEl) return;\n\t\tconst canvas = document.createElement('canvas');\n\t\tcanvas.width = videoEl.videoWidth;\n\t\tcanvas.height = videoEl.videoHeight;\n\t\tconst ctx = canvas.getContext('2d');\n\t\tif (ctx) {\n\t\t\tctx.drawImage(videoEl, 0, 0);\n\t\t\tcanvas.toBlob((blob) => {\n\t\t\t\tif (blob) {\n\t\t\t\t\tonchange?.(blob);\n\t\t\t\t}\n\t\t\t\tstopWebcam();\n\t\t\t}, 'image/png');\n\t\t}\n\t}\n\n\tfunction stopWebcam() {\n\t\tif (stream) {\n\t\t\tstream.getTracks().forEach(track => track.stop());\n\t\t\tstream = null;\n\t\t}\n\t\tshowWebcam = false;\n\t}\n\n\tfunction clearImage() {\n\t\tonchange?.(null);\n\t}\n\n\tasync function pasteFromClipboard() {\n\t\ttry {\n\t\t\tconst items = await navigator.clipboard.read();\n\t\t\tfor (const item of items) {\n\t\t\t\tconst imageType = item.types.find(type => type.startsWith('image/'));\n\t\t\t\tif (imageType) {\n\t\t\t\t\tconst blob = await item.getType(imageType);\n\t\t\t\t\tonchange?.(blob);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to paste from clipboard:', e);\n\t\t}\n\t}\n</script>\n\n<div class=\"gr-image-wrap\">\n\t<input\n\t\tbind:this={fileInputEl}\n\t\ttype=\"file\"\n\t\taccept=\"image/*\"\n\t\tstyle=\"display: none\"\n\t\tonchange={handleFileSelect}\n\t/>\n\t\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"image-actions\">\n\t\t\t{#if showWebcam}\n\t\t\t\t<button class=\"action-btn capture\" onclick={captureFromWebcam} title=\"Capture photo\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n\t\t\t\t\t\t<circle cx=\"12\" cy=\"12\" r=\"10\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={stopWebcam} title=\"Cancel\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{:else if src}\n\t\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={downloadImage} title=\"Download\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"7 10 12 15 17 10\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={clearImage} title=\"Clear image\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{:else if editable}\n\t\t\t\t<button class=\"action-btn\" onclick={triggerUpload} title=\"Upload image\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={pasteFromClipboard} title=\"Paste from clipboard\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"/>\n\t\t\t\t\t\t<rect x=\"8\" y=\"2\" width=\"8\" height=\"4\" rx=\"1\" ry=\"1\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={startWebcam} title=\"Capture from webcam\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z\"/>\n\t\t\t\t\t\t<circle cx=\"12\" cy=\"13\" r=\"4\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\t\n\t{#if showWebcam}\n\t\t<div class=\"webcam-container\">\n\t\t\t<video bind:this={videoEl} autoplay playsinline muted></video>\n\t\t</div>\n\t{:else if src}\n\t\t<div class=\"image-container\">\n\t\t\t<img bind:this={imgEl} class=\"gr-image\" {src} alt={label} />\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No image</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-image-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.image-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.action-btn.capture {\n\t\tbackground: var(--error-border-color);\n\t}\n\n\t.action-btn.capture svg {\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t.action-btn.capture:hover {\n\t\tbackground: var(--error-text-color);\n\t}\n\n\t.image-container {\n\t\tpadding: 0 6px 6px;\n\t}\n\n\t.gr-image {\n\t\twidth: 100%;\n\t\tmax-height: 80px;\n\t\tobject-fit: contain;\n\t\tdisplay: block;\n\t\tborder-radius: 4px;\n\t}\n\n\t.webcam-container {\n\t\tpadding: 0 6px 6px;\n\t}\n\n\t.webcam-container video {\n\t\twidth: 100%;\n\t\tmax-height: 120px;\n\t\tobject-fit: contain;\n\t\tborder-radius: 4px;\n\t\tbackground: var(--body-background-fill);\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n</style>\n"
  },
  {
    "path": "daggr/frontend/src/components/ImageSlider.svelte",
    "content": "<script lang=\"ts\">\n\tinterface ImageValue {\n\t\turl: string;\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: [ImageValue | null, ImageValue | null] | null;\n\t\tposition?: number;\n\t\tsliderColor?: string;\n\t\teditable?: boolean;\n\t\tonchange?: (value: [ImageValue | null, ImageValue | null]) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tposition = 50,\n\t\tsliderColor = '#f97316',\n\t\teditable = false,\n\t\tonchange \n\t}: Props = $props();\n\n\tlet containerEl: HTMLDivElement | null = $state(null);\n\tlet isDragging = $state(false);\n\tlet sliderPosition = $state(position);\n\n\tlet image1 = $derived(value?.[0]?.url || null);\n\tlet image2 = $derived(value?.[1]?.url || null);\n\n\tfunction handleMouseDown(e: MouseEvent) {\n\t\tisDragging = true;\n\t\tupdatePosition(e);\n\t}\n\n\tfunction handleMouseMove(e: MouseEvent) {\n\t\tif (!isDragging) return;\n\t\tupdatePosition(e);\n\t}\n\n\tfunction handleMouseUp() {\n\t\tisDragging = false;\n\t}\n\n\tfunction handleTouchStart(e: TouchEvent) {\n\t\tisDragging = true;\n\t\tupdatePositionTouch(e);\n\t}\n\n\tfunction handleTouchMove(e: TouchEvent) {\n\t\tif (!isDragging) return;\n\t\tupdatePositionTouch(e);\n\t}\n\n\tfunction updatePosition(e: MouseEvent) {\n\t\tif (!containerEl) return;\n\t\tconst rect = containerEl.getBoundingClientRect();\n\t\tconst x = e.clientX - rect.left;\n\t\tconst percent = Math.max(0, Math.min(100, (x / rect.width) * 100));\n\t\tsliderPosition = percent;\n\t}\n\n\tfunction updatePositionTouch(e: TouchEvent) {\n\t\tif (!containerEl || !e.touches[0]) return;\n\t\tconst rect = containerEl.getBoundingClientRect();\n\t\tconst x = e.touches[0].clientX - rect.left;\n\t\tconst percent = Math.max(0, Math.min(100, (x / rect.width) * 100));\n\t\tsliderPosition = percent;\n\t}\n\n\tlet fileInputEl: HTMLInputElement | null = $state(null);\n\tlet uploadingIndex = $state<0 | 1>(0);\n\n\tfunction triggerUpload(index: 0 | 1) {\n\t\tuploadingIndex = index;\n\t\tfileInputEl?.click();\n\t}\n\n\tfunction handleFileSelect(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst file = target.files?.[0];\n\t\tif (file) {\n\t\t\tconst url = URL.createObjectURL(file);\n\t\t\tconst newValue: [ImageValue | null, ImageValue | null] = value ? [...value] : [null, null];\n\t\t\tnewValue[uploadingIndex] = { url };\n\t\t\tonchange?.(newValue);\n\t\t}\n\t\ttarget.value = '';\n\t}\n</script>\n\n<svelte:window \n\tonmousemove={handleMouseMove} \n\tonmouseup={handleMouseUp}\n\tontouchmove={handleTouchMove}\n\tontouchend={handleMouseUp}\n/>\n\n<div class=\"gr-imageslider-wrap\">\n\t<input\n\t\tbind:this={fileInputEl}\n\t\ttype=\"file\"\n\t\taccept=\"image/*\"\n\t\tstyle=\"display: none\"\n\t\tonchange={handleFileSelect}\n\t/>\n\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t{#if editable}\n\t\t\t<div class=\"slider-actions\">\n\t\t\t\t<button class=\"action-btn\" onclick={() => triggerUpload(0)} title=\"Upload left image\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={() => triggerUpload(1)} title=\"Upload right image\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n\n\t{#if image1 || image2}\n\t\t<div \n\t\t\tclass=\"slider-container\"\n\t\t\tbind:this={containerEl}\n\t\t\tonmousedown={handleMouseDown}\n\t\t\tontouchstart={handleTouchStart}\n\t\t>\n\t\t\t<div class=\"image-layer image-before\" style:clip-path=\"inset(0 {100 - sliderPosition}% 0 0)\">\n\t\t\t\t{#if image1}\n\t\t\t\t\t<img src={image1} alt=\"Before\" />\n\t\t\t\t{:else}\n\t\t\t\t\t<div class=\"empty-image\">No image</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\t\t\t\n\t\t\t<div class=\"image-layer image-after\">\n\t\t\t\t{#if image2}\n\t\t\t\t\t<img src={image2} alt=\"After\" />\n\t\t\t\t{:else}\n\t\t\t\t\t<div class=\"empty-image\">No image</div>\n\t\t\t\t{/if}\n\t\t\t</div>\n\n\t\t\t<div class=\"slider-line\" style:left=\"{sliderPosition}%\" style:background-color={sliderColor}>\n\t\t\t\t<div class=\"slider-handle\" style:border-color={sliderColor}>\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polyline points=\"15 18 9 12 15 6\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polyline points=\"9 18 15 12 9 6\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div class=\"labels\">\n\t\t\t\t<span class=\"label-badge left\">Before</span>\n\t\t\t\t<span class=\"label-badge right\">After</span>\n\t\t\t</div>\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No images to compare</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-imageslider-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.slider-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.slider-container {\n\t\tposition: relative;\n\t\tmargin: 0 6px 6px;\n\t\tborder-radius: 4px;\n\t\toverflow: hidden;\n\t\tcursor: ew-resize;\n\t\taspect-ratio: 16/9;\n\t\tbackground: var(--body-background-fill);\n\t}\n\n\t.image-layer {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\twidth: 100%;\n\t\theight: 100%;\n\t}\n\n\t.image-layer img {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tobject-fit: contain;\n\t}\n\n\t.image-before {\n\t\tz-index: 2;\n\t}\n\n\t.image-after {\n\t\tz-index: 1;\n\t}\n\n\t.empty-image {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-size: 11px;\n\t\tfont-style: italic;\n\t\tbackground: var(--block-background-fill);\n\t}\n\n\t.slider-line {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tbottom: 0;\n\t\twidth: 2px;\n\t\ttransform: translateX(-50%);\n\t\tz-index: 10;\n\t\tpointer-events: none;\n\t}\n\n\t.slider-handle {\n\t\tposition: absolute;\n\t\ttop: 50%;\n\t\tleft: 50%;\n\t\ttransform: translate(-50%, -50%);\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 2px solid;\n\t\tborder-radius: 50%;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tpointer-events: none;\n\t}\n\n\t.slider-handle svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.labels {\n\t\tposition: absolute;\n\t\ttop: 8px;\n\t\tleft: 8px;\n\t\tright: 8px;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\tz-index: 5;\n\t\tpointer-events: none;\n\t}\n\n\t.label-badge {\n\t\tfont-size: 9px;\n\t\tpadding: 2px 6px;\n\t\tbackground: rgba(0, 0, 0, 0.6);\n\t\tcolor: #fff;\n\t\tborder-radius: 3px;\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/ItemListSection.svelte",
    "content": "<script lang=\"ts\">\n\timport type { GradioComponentData, ItemListItem } from '../types';\n\n\tinterface Props {\n\t\tnodeId: string;\n\t\tschema: GradioComponentData[];\n\t\titems: ItemListItem[];\n\t\tgetValue: (nodeId: string, itemIndex: number, fieldName: string) => any;\n\t\tonchange?: (nodeId: string, itemIndex: number, fieldName: string, value: any) => void;\n\t}\n\n\tlet { nodeId, schema, items, getValue, onchange }: Props = $props();\n\n\tfunction handleFieldChange(itemIndex: number, fieldName: string, value: any) {\n\t\tonchange?.(nodeId, itemIndex, fieldName, value);\n\t}\n</script>\n\n<div class=\"item-list-section\">\n\t<div class=\"item-list-header\">\n\t\t<span class=\"item-list-title\">Items ({items.length})</span>\n\t</div>\n\t<div class=\"item-list-items\">\n\t\t{#each items as item (item.index)}\n\t\t\t<div class=\"item-list-item\">\n\t\t\t\t<div class=\"item-list-fields\">\n\t\t\t\t\t{#each schema as comp (comp.port_name)}\n\t\t\t\t\t\t{#if comp.component === 'dropdown'}\n\t\t\t\t\t\t\t{@const currentValue = getValue(nodeId, item.index, comp.port_name)}\n\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\tclass=\"gr-select\"\n\t\t\t\t\t\t\t\tonchange={(e) => handleFieldChange(item.index, comp.port_name, (e.target as HTMLSelectElement).value)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#each comp.props?.choices || [] as choice}\n\t\t\t\t\t\t\t\t\t<option value={choice} selected={choice === currentValue}>{choice}</option>\n\t\t\t\t\t\t\t\t{/each}\n\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t{:else if comp.component === 'textbox' || comp.component === 'text'}\n\t\t\t\t\t\t\t<div class=\"gr-textbox-wrap item-list-textbox\">\n\t\t\t\t\t\t\t\t{#if comp.props?.lines && comp.props.lines > 1}\n\t\t\t\t\t\t\t\t\t<textarea\n\t\t\t\t\t\t\t\t\t\tclass=\"gr-input\"\n\t\t\t\t\t\t\t\t\t\trows={comp.props?.lines || 2}\n\t\t\t\t\t\t\t\t\t\tvalue={getValue(nodeId, item.index, comp.port_name)}\n\t\t\t\t\t\t\t\t\t\toninput={(e) => handleFieldChange(item.index, comp.port_name, (e.target as HTMLTextAreaElement).value)}\n\t\t\t\t\t\t\t\t\t></textarea>\n\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\ttype=\"text\"\n\t\t\t\t\t\t\t\t\t\tclass=\"gr-input\"\n\t\t\t\t\t\t\t\t\t\tvalue={getValue(nodeId, item.index, comp.port_name)}\n\t\t\t\t\t\t\t\t\t\toninput={(e) => handleFieldChange(item.index, comp.port_name, (e.target as HTMLInputElement).value)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t{/each}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{/each}\n\t</div>\n</div>\n\n<style>\n\t.item-list-section {\n\t\tborder-top: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 20%, transparent);\n\t\tbackground: color-mix(in srgb, var(--primary-500, #22c55e) 3%, transparent);\n\t}\n\n\t.item-list-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px 10px;\n\t\tborder-bottom: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 10%, transparent);\n\t}\n\n\t.item-list-title {\n\t\tfont-size: 10px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--primary-500, #22c55e);\n\t\ttext-transform: uppercase;\n\t\tletter-spacing: 0.5px;\n\t}\n\n\t.item-list-items {\n\t\tmax-height: 300px;\n\t\toverflow-y: auto;\n\t\tscrollbar-width: thin;\n\t\tscrollbar-color: rgba(136, 136, 136, 0.4) transparent;\n\t}\n\n\t.item-list-items::-webkit-scrollbar {\n\t\twidth: 6px;\n\t\theight: 6px;\n\t}\n\n\t.item-list-items::-webkit-scrollbar-track {\n\t\tbackground: transparent;\n\t}\n\n\t.item-list-items::-webkit-scrollbar-thumb {\n\t\tbackground-color: rgba(136, 136, 136, 0.3);\n\t\tborder-radius: 10px;\n\t\tborder: 1px solid transparent;\n\t\tbackground-clip: content-box;\n\t}\n\n\t.item-list-items::-webkit-scrollbar-thumb:hover {\n\t\tbackground-color: var(--color-accent);\n\t}\n\n\t.item-list-item {\n\t\tdisplay: flex;\n\t\talign-items: flex-start;\n\t\tgap: 8px;\n\t\tpadding: 8px 10px;\n\t\tborder-bottom: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 8%, transparent);\n\t}\n\n\t.item-list-item:last-child {\n\t\tborder-bottom: none;\n\t}\n\n\t.item-list-fields {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 6px;\n\t}\n\n\t.gr-select {\n\t\twidth: 100%;\n\t\tpadding: 6px 8px;\n\t\tfont-size: 11px;\n\t\tbackground: color-mix(in srgb, var(--primary-500, #22c55e) 8%, transparent);\n\t\tborder: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 20%, transparent);\n\t\tborder-radius: 4px;\n\t\tcolor: var(--body-text-color);\n\t\tcursor: pointer;\n\t}\n\n\t.gr-select:focus {\n\t\toutline: none;\n\t\tborder-color: color-mix(in srgb, var(--primary-500, #22c55e) 50%, transparent);\n\t}\n\n\t.gr-textbox-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.item-list-textbox {\n\t\tflex: 1;\n\t}\n\n\t.gr-input {\n\t\twidth: 100%;\n\t\tpadding: 6px 10px;\n\t\tfont-size: 11px;\n\t\tfont-family: inherit;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\toutline: none;\n\t\tbox-sizing: border-box;\n\t}\n\n\t.item-list-textbox textarea.gr-input {\n\t\tresize: vertical;\n\t\tmin-height: 40px;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Json.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: any;\n\t\topen?: boolean | number;\n\t\tshowIndices?: boolean;\n\t\teditable?: boolean;\n\t\tonchange?: (value: any) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\topen = 2,\n\t\tshowIndices = true,\n\t\teditable = false,\n\t\tonchange,\n\t}: Props = $props();\n\n\tlet copySuccess = $state(false);\n\tlet containerEl: HTMLDivElement | null = $state(null);\n\tlet isFullscreen = $state(false);\n\tlet editText = $state('');\n\tlet parseError = $state('');\n\n\t$effect(() => {\n\t\tif (editable && value !== undefined && value !== null) {\n\t\t\teditText = typeof value === 'string' ? value : JSON.stringify(value, null, 2);\n\t\t}\n\t});\n\n\tfunction handleEdit(e: Event) {\n\t\tconst text = (e.target as HTMLTextAreaElement).value;\n\t\teditText = text;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(text);\n\t\t\tparseError = '';\n\t\t\tonchange?.(parsed);\n\t\t} catch {\n\t\t\tparseError = 'Invalid JSON';\n\t\t}\n\t}\n\n\tfunction copyJson() {\n\t\tconst text = JSON.stringify(value, null, 2);\n\t\tnavigator.clipboard.writeText(text).then(() => {\n\t\t\tcopySuccess = true;\n\t\t\tsetTimeout(() => copySuccess = false, 1500);\n\t\t});\n\t}\n\n\tfunction openFullscreen() {\n\t\tif (!containerEl) return;\n\t\tif (containerEl.requestFullscreen) {\n\t\t\tcontainerEl.requestFullscreen();\n\t\t} else if ((containerEl as any).webkitRequestFullscreen) {\n\t\t\t(containerEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction handleFullscreenChange() {\n\t\tisFullscreen = !!document.fullscreenElement;\n\t}\n</script>\n\n<svelte:document onfullscreenchange={handleFullscreenChange} />\n\n<div class=\"gr-json-wrap\" class:fullscreen={isFullscreen} bind:this={containerEl}>\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"json-actions\">\n\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t\t{#if value !== null && value !== undefined}\n\t\t\t\t<button class=\"action-btn\" class:success={copySuccess} onclick={copyJson} title=\"Copy JSON\">\n\t\t\t\t\t{#if copySuccess}\n\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t<polyline points=\"20 6 9 17 4 12\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t\t<rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"/>\n\t\t\t\t\t\t\t<path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t{/if}\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\n\t{#if editable}\n\t\t<div class=\"json-edit-wrap\">\n\t\t\t<textarea\n\t\t\t\tclass=\"json-textarea\"\n\t\t\t\tclass:has-error={parseError}\n\t\t\t\tvalue={editText}\n\t\t\t\toninput={handleEdit}\n\t\t\t\tplaceholder={'{\"key\": \"value\"}'}\n\t\t\t\tspellcheck=\"false\"\n\t\t\t></textarea>\n\t\t\t{#if parseError}\n\t\t\t\t<span class=\"json-parse-error\">{parseError}</span>\n\t\t\t{/if}\n\t\t</div>\n\t{:else if value !== null && value !== undefined}\n\t\t<div class=\"json-content\">\n\t\t\t{@render JsonNode({ value, depth: 0, maxOpen: typeof open === 'number' ? open : (open ? Infinity : 0), showIndices })}\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">null</div>\n\t{/if}\n</div>\n\n{#snippet JsonNode(props: { value: any; depth: number; maxOpen: number; showIndices: boolean; key?: string | number })}\n\t{@const { value: nodeValue, depth, maxOpen, showIndices, key } = props}\n\t{@const isOpen = depth < maxOpen}\n\t\n\t{#if Array.isArray(nodeValue)}\n\t\t<div class=\"json-node\">\n\t\t\t{#if key !== undefined}\n\t\t\t\t<span class=\"json-key\">{showIndices && typeof key === 'number' ? `[${key}]` : `\"${key}\"`}</span>\n\t\t\t\t<span class=\"json-colon\">: </span>\n\t\t\t{/if}\n\t\t\t<details open={isOpen}>\n\t\t\t\t<summary class=\"json-bracket\">[<span class=\"json-preview\">{nodeValue.length} items</span>]</summary>\n\t\t\t\t<div class=\"json-children\">\n\t\t\t\t\t{#each nodeValue as item, i}\n\t\t\t\t\t\t{@render JsonNode({ value: item, depth: depth + 1, maxOpen, showIndices, key: i })}\n\t\t\t\t\t{/each}\n\t\t\t\t</div>\n\t\t\t</details>\n\t\t</div>\n\t{:else if typeof nodeValue === 'object' && nodeValue !== null}\n\t\t<div class=\"json-node\">\n\t\t\t{#if key !== undefined}\n\t\t\t\t<span class=\"json-key\">{showIndices && typeof key === 'number' ? `[${key}]` : `\"${key}\"`}</span>\n\t\t\t\t<span class=\"json-colon\">: </span>\n\t\t\t{/if}\n\t\t\t<details open={isOpen}>\n\t\t\t\t<summary class=\"json-bracket\">&#123;<span class=\"json-preview\">{Object.keys(nodeValue).length} keys</span>&#125;</summary>\n\t\t\t\t<div class=\"json-children\">\n\t\t\t\t\t{#each Object.entries(nodeValue) as [k, v]}\n\t\t\t\t\t\t{@render JsonNode({ value: v, depth: depth + 1, maxOpen, showIndices, key: k })}\n\t\t\t\t\t{/each}\n\t\t\t\t</div>\n\t\t\t</details>\n\t\t</div>\n\t{:else}\n\t\t<div class=\"json-node json-leaf\">\n\t\t\t{#if key !== undefined}\n\t\t\t\t<span class=\"json-key\">{showIndices && typeof key === 'number' ? `[${key}]` : `\"${key}\"`}</span>\n\t\t\t\t<span class=\"json-colon\">: </span>\n\t\t\t{/if}\n\t\t\t{#if typeof nodeValue === 'string'}\n\t\t\t\t<span class=\"json-string\">\"{nodeValue}\"</span>\n\t\t\t{:else if typeof nodeValue === 'number'}\n\t\t\t\t<span class=\"json-number\">{nodeValue}</span>\n\t\t\t{:else if typeof nodeValue === 'boolean'}\n\t\t\t\t<span class=\"json-boolean\">{nodeValue ? 'true' : 'false'}</span>\n\t\t\t{:else if nodeValue === null}\n\t\t\t\t<span class=\"json-null\">null</span>\n\t\t\t{:else}\n\t\t\t\t<span class=\"json-undefined\">undefined</span>\n\t\t\t{/if}\n\t\t</div>\n\t{/if}\n{/snippet}\n\n<style>\n\t.gr-json-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-json-wrap.fullscreen {\n\t\tborder-radius: 0;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\theight: 100vh;\n\t}\n\n\t.gr-json-wrap.fullscreen .json-content {\n\t\tflex: 1;\n\t\toverflow: auto;\n\t\tfont-size: 14px;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.json-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.action-btn.success svg {\n\t\tcolor: var(--primary-500, #22c55e);\n\t}\n\n\t.json-content {\n\t\tpadding: 0 10px 10px;\n\t\tfont-family: 'SF Mono', Monaco, Consolas, monospace;\n\t\tfont-size: 11px;\n\t\tline-height: 1.5;\n\t\toverflow-x: auto;\n\t}\n\n\t.json-node {\n\t\tdisplay: block;\n\t}\n\n\t.json-leaf {\n\t\tpadding-left: 0;\n\t}\n\n\t.json-children {\n\t\tpadding-left: 16px;\n\t\tborder-left: 1px solid var(--border-color-primary);\n\t\tmargin-left: 4px;\n\t}\n\n\t.json-key {\n\t\tcolor: var(--secondary-400, #93c5fd);\n\t}\n\n\t.json-colon {\n\t\tcolor: var(--neutral-500);\n\t}\n\n\t.json-string {\n\t\tcolor: var(--primary-300, #a5d6a7);\n\t}\n\n\t.json-number {\n\t\tcolor: var(--color-accent-soft, #ffcc80);\n\t}\n\n\t.json-boolean {\n\t\tcolor: var(--secondary-300, #ce93d8);\n\t}\n\n\t.json-null {\n\t\tcolor: var(--error-text-color, #ef9a9a);\n\t}\n\n\t.json-undefined {\n\t\tcolor: var(--neutral-500);\n\t}\n\n\t.json-bracket {\n\t\tcolor: var(--body-text-color-subdued);\n\t\tcursor: pointer;\n\t\tlist-style: none;\n\t\tdisplay: inline;\n\t}\n\n\t.json-bracket::-webkit-details-marker {\n\t\tdisplay: none;\n\t}\n\n\t.json-bracket::before {\n\t\tcontent: '▶';\n\t\tdisplay: inline-block;\n\t\twidth: 12px;\n\t\tfont-size: 8px;\n\t\tcolor: var(--neutral-500);\n\t\ttransition: transform 0.15s;\n\t}\n\n\tdetails[open] > .json-bracket::before {\n\t\ttransform: rotate(90deg);\n\t}\n\n\t.json-preview {\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-size: 10px;\n\t\tmargin-left: 4px;\n\t}\n\n\tdetails[open] > .json-bracket .json-preview {\n\t\tdisplay: none;\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px 10px;\n\t}\n\n\t.json-edit-wrap {\n\t\tpadding: 0 6px 6px;\n\t}\n\n\t.json-textarea {\n\t\twidth: 100%;\n\t\tmin-height: 60px;\n\t\tmax-height: 200px;\n\t\tpadding: 6px 8px;\n\t\tfont-family: 'SF Mono', Monaco, Consolas, monospace;\n\t\tfont-size: 11px;\n\t\tline-height: 1.5;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 4px;\n\t\toutline: none;\n\t\tresize: vertical;\n\t\tbox-sizing: border-box;\n\t}\n\n\t.json-textarea:focus {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.json-textarea.has-error {\n\t\tborder-color: var(--error-text-color, #ef5350);\n\t}\n\n\t.json-textarea::placeholder {\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.json-parse-error {\n\t\tdisplay: block;\n\t\tfont-size: 9px;\n\t\tcolor: var(--error-text-color, #ef5350);\n\t\tmargin-top: 2px;\n\t\tpadding-left: 2px;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Label.svelte",
    "content": "<script lang=\"ts\">\n\tinterface LabelValue {\n\t\tlabel: string;\n\t\tconfidences?: { label: string; confidence: number }[];\n\t}\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: LabelValue;\n\t\tshowHeading?: boolean;\n\t\tcolor?: string;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tshowHeading = true,\n\t\tcolor\n\t}: Props = $props();\n\n\tlet sortedConfidences = $derived.by(() => {\n\t\tif (!value.confidences) return [];\n\t\treturn [...value.confidences].sort((a, b) => b.confidence - a.confidence);\n\t});\n\n\tfunction formatConfidence(conf: number): string {\n\t\treturn (conf * 100).toFixed(1) + '%';\n\t}\n</script>\n\n<div class=\"gr-label-wrap\">\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t</div>\n\n\t{#if value.label}\n\t\t{#if showHeading}\n\t\t\t<div class=\"main-label\" style:color={color}>\n\t\t\t\t{value.label}\n\t\t\t</div>\n\t\t{/if}\n\n\t\t{#if sortedConfidences.length > 0}\n\t\t\t<div class=\"confidences\">\n\t\t\t\t{#each sortedConfidences as item, i}\n\t\t\t\t\t<div class=\"confidence-item\" class:top={i === 0}>\n\t\t\t\t\t\t<div class=\"confidence-header\">\n\t\t\t\t\t\t\t<span class=\"confidence-label\">{item.label}</span>\n\t\t\t\t\t\t\t<span class=\"confidence-value\">{formatConfidence(item.confidence)}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"confidence-bar-bg\">\n\t\t\t\t\t\t\t<div \n\t\t\t\t\t\t\t\tclass=\"confidence-bar\" \n\t\t\t\t\t\t\t\tstyle:width=\"{item.confidence * 100}%\"\n\t\t\t\t\t\t\t\tclass:top={i === 0}\n\t\t\t\t\t\t\t></div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t{/each}\n\t\t\t</div>\n\t\t{/if}\n\t{:else}\n\t\t<div class=\"gr-empty\">No label</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-label-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.main-label {\n\t\tpadding: 8px 10px;\n\t\tfont-size: 18px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--color-accent);\n\t\ttext-align: center;\n\t}\n\n\t.confidences {\n\t\tpadding: 0 10px 10px;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 6px;\n\t}\n\n\t.confidence-item {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 3px;\n\t}\n\n\t.confidence-header {\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t\talign-items: center;\n\t}\n\n\t.confidence-label {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.confidence-item.top .confidence-label {\n\t\tfont-weight: 500;\n\t}\n\n\t.confidence-value {\n\t\tfont-size: 10px;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.confidence-bar-bg {\n\t\theight: 4px;\n\t\tbackground: var(--input-background-fill);\n\t\tborder-radius: 2px;\n\t\toverflow: hidden;\n\t}\n\n\t.confidence-bar {\n\t\theight: 100%;\n\t\tbackground: var(--neutral-500);\n\t\tborder-radius: 2px;\n\t\ttransition: width 0.3s ease;\n\t}\n\n\t.confidence-bar.top {\n\t\tbackground: var(--color-accent);\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px 10px;\n\t\ttext-align: center;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/MapItemsSection.svelte",
    "content": "<script lang=\"ts\">\n\timport AudioPlayer from './AudioPlayer.svelte';\n\timport type { MapItem } from '../types';\n\n\tinterface Props {\n\t\tnodeId: string;\n\t\tnodeName: string;\n\t\titems: MapItem[];\n\t\tonReplayItem?: (nodeName: string, index: number) => void;\n\t}\n\n\tlet { nodeId, nodeName, items, onReplayItem }: Props = $props();\n\n\tfunction handleReplay(e: MouseEvent, index: number) {\n\t\te.stopPropagation();\n\t\tonReplayItem?.(nodeName, index);\n\t}\n</script>\n\n<div class=\"map-items-section\">\n\t<div class=\"map-items-header\">\n\t\t<span class=\"map-items-title\">Items ({items.length})</span>\n\t</div>\n\t<div class=\"map-items-list\">\n\t\t{#each items as item (item.index)}\n\t\t\t<div class=\"map-item\" class:has-output={item.output}>\n\t\t\t\t<div class=\"map-item-content\">\n\t\t\t\t\t{#if item.is_audio_output && item.output}\n\t\t\t\t\t\t<AudioPlayer \n\t\t\t\t\t\t\tsrc={item.output} \n\t\t\t\t\t\t\tid=\"{nodeId}_map_{item.index}\" \n\t\t\t\t\t\t\tcompact={true}\n\t\t\t\t\t\t/>\n\t\t\t\t\t{:else if item.output}\n\t\t\t\t\t\t<span class=\"map-item-preview\" title={item.output}>\n\t\t\t\t\t\t\t{item.output.length > 40 ? item.output.slice(0, 40) + '...' : item.output}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<span class=\"map-item-preview map-item-pending\">Pending...</span>\n\t\t\t\t\t{/if}\n\t\t\t\t</div>\n\t\t\t\t<button \n\t\t\t\t\tclass=\"map-item-replay\"\n\t\t\t\t\tonclick={(e) => handleReplay(e, item.index)}\n\t\t\t\t\ttitle={item.output ? \"Replay this item\" : \"Run this item\"}\n\t\t\t\t>\n\t\t\t\t\t{item.output ? '↻' : '▶'}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t{/each}\n\t</div>\n</div>\n\n<style>\n\t.map-items-section {\n\t\tborder-top: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 20%, transparent);\n\t\tbackground: color-mix(in srgb, var(--primary-500, #22c55e) 3%, transparent);\n\t}\n\n\t.map-items-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px 10px;\n\t\tborder-bottom: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 10%, transparent);\n\t}\n\n\t.map-items-title {\n\t\tfont-size: 10px;\n\t\tfont-weight: 600;\n\t\tcolor: var(--primary-500, #22c55e);\n\t\ttext-transform: uppercase;\n\t\tletter-spacing: 0.5px;\n\t}\n\n\t.map-items-list {\n\t\tmax-height: 300px;\n\t\toverflow-y: auto;\n\t}\n\n\t.map-item {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 8px 10px;\n\t\tborder-bottom: 1px solid color-mix(in srgb, var(--primary-500, #22c55e) 8%, transparent);\n\t}\n\n\t.map-item:last-child {\n\t\tborder-bottom: none;\n\t}\n\n\t.map-item-content {\n\t\tflex: 1;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tgap: 6px;\n\t}\n\n\t.map-item-preview {\n\t\tflex: 1;\n\t\tfont-size: 10px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t}\n\n\t.map-item.has-output .map-item-preview {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.map-item-pending {\n\t\tcolor: var(--neutral-500);\n\t\tfont-style: italic;\n\t}\n\n\t.map-item-replay {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--primary-500, #22c55e) 15%, transparent);\n\t\tcolor: var(--primary-500, #22c55e);\n\t\tfont-size: 10px;\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: all 0.15s;\n\t\tflex-shrink: 0;\n\t\talign-self: flex-start;\n\t\tmargin-top: 4px;\n\t}\n\n\t.map-item-replay:hover {\n\t\tbackground: color-mix(in srgb, var(--primary-500, #22c55e) 30%, transparent);\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Markdown.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel?: string;\n\t\tvalue: string;\n\t\tshowLabel?: boolean;\n\t}\n\n\tlet { \n\t\tlabel = '', \n\t\tvalue, \n\t\tshowLabel = false\n\t}: Props = $props();\n\n\tlet containerEl: HTMLDivElement | null = $state(null);\n\tlet isFullscreen = $state(false);\n\n\tfunction openFullscreen() {\n\t\tif (!containerEl) return;\n\t\tif (containerEl.requestFullscreen) {\n\t\t\tcontainerEl.requestFullscreen();\n\t\t} else if ((containerEl as any).webkitRequestFullscreen) {\n\t\t\t(containerEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction handleFullscreenChange() {\n\t\tisFullscreen = !!document.fullscreenElement;\n\t}\n\n\tfunction parseMarkdown(text: string): string {\n\t\tif (!text) return '';\n\t\t\n\t\tlet html = text\n\t\t\t.replace(/&/g, '&amp;')\n\t\t\t.replace(/</g, '&lt;')\n\t\t\t.replace(/>/g, '&gt;');\n\n\t\thtml = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');\n\t\thtml = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');\n\t\thtml = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');\n\n\t\thtml = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');\n\t\thtml = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>');\n\t\thtml = html.replace(/`(.+?)`/g, '<code>$1</code>');\n\t\thtml = html.replace(/~~(.+?)~~/g, '<del>$1</del>');\n\n\t\thtml = html.replace(/\\[(.+?)\\]\\((.+?)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n\n\t\thtml = html.replace(/^- (.+)$/gm, '<li>$1</li>');\n\t\thtml = html.replace(/(<li>.*<\\/li>\\n?)+/g, '<ul>$&</ul>');\n\n\t\thtml = html.replace(/^\\d+\\. (.+)$/gm, '<li>$1</li>');\n\n\t\thtml = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');\n\n\t\thtml = html.replace(/^---$/gm, '<hr>');\n\n\t\thtml = html.replace(/\\n\\n/g, '</p><p>');\n\t\thtml = '<p>' + html + '</p>';\n\t\thtml = html.replace(/<p><\\/p>/g, '');\n\t\thtml = html.replace(/<p>(<h[1-6]>)/g, '$1');\n\t\thtml = html.replace(/(<\\/h[1-6]>)<\\/p>/g, '$1');\n\t\thtml = html.replace(/<p>(<ul>)/g, '$1');\n\t\thtml = html.replace(/(<\\/ul>)<\\/p>/g, '$1');\n\t\thtml = html.replace(/<p>(<blockquote>)/g, '$1');\n\t\thtml = html.replace(/(<\\/blockquote>)<\\/p>/g, '$1');\n\t\thtml = html.replace(/<p>(<hr>)<\\/p>/g, '$1');\n\n\t\treturn html;\n\t}\n\n\tlet renderedHtml = $derived(parseMarkdown(value));\n</script>\n\n<svelte:document onfullscreenchange={handleFullscreenChange} />\n\n<div class=\"gr-markdown-wrap\" class:fullscreen={isFullscreen} bind:this={containerEl}>\n\t<div class=\"gr-header\">\n\t\t{#if showLabel && label}\n\t\t\t<span class=\"gr-label\">{label}</span>\n\t\t{:else}\n\t\t\t<span></span>\n\t\t{/if}\n\t\t<div class=\"markdown-actions\">\n\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t</div>\n\t</div>\n\t\n\t<div class=\"markdown-content\">\n\t\t{@html renderedHtml}\n\t</div>\n</div>\n\n<style>\n\t.gr-markdown-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-markdown-wrap.fullscreen {\n\t\tborder-radius: 0;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\theight: 100vh;\n\t}\n\n\t.gr-markdown-wrap.fullscreen .markdown-content {\n\t\tflex: 1;\n\t\toverflow: auto;\n\t\tfont-size: 16px;\n\t\tpadding: 24px;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.markdown-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.markdown-content {\n\t\tpadding: 10px;\n\t\tfont-size: 12px;\n\t\tline-height: 1.6;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.markdown-content :global(h1) {\n\t\tfont-size: 18px;\n\t\tfont-weight: 600;\n\t\tmargin: 0 0 12px 0;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.markdown-content :global(h2) {\n\t\tfont-size: 15px;\n\t\tfont-weight: 600;\n\t\tmargin: 12px 0 8px 0;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.markdown-content :global(h3) {\n\t\tfont-size: 13px;\n\t\tfont-weight: 600;\n\t\tmargin: 10px 0 6px 0;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.markdown-content :global(p) {\n\t\tmargin: 0 0 8px 0;\n\t}\n\n\t.markdown-content :global(p:last-child) {\n\t\tmargin-bottom: 0;\n\t}\n\n\t.markdown-content :global(strong) {\n\t\tfont-weight: 600;\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.markdown-content :global(em) {\n\t\tfont-style: italic;\n\t}\n\n\t.markdown-content :global(code) {\n\t\tfont-family: 'SF Mono', Monaco, Consolas, monospace;\n\t\tfont-size: 11px;\n\t\tbackground: var(--input-background-fill);\n\t\tpadding: 2px 4px;\n\t\tborder-radius: 3px;\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.markdown-content :global(del) {\n\t\ttext-decoration: line-through;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.markdown-content :global(a) {\n\t\tcolor: var(--secondary-400, #60a5fa);\n\t\ttext-decoration: none;\n\t}\n\n\t.markdown-content :global(a:hover) {\n\t\ttext-decoration: underline;\n\t}\n\n\t.markdown-content :global(ul) {\n\t\tmargin: 0 0 8px 0;\n\t\tpadding-left: 20px;\n\t}\n\n\t.markdown-content :global(li) {\n\t\tmargin: 2px 0;\n\t}\n\n\t.markdown-content :global(blockquote) {\n\t\tmargin: 8px 0;\n\t\tpadding: 8px 12px;\n\t\tborder-left: 3px solid var(--color-accent);\n\t\tbackground: color-mix(in srgb, var(--color-accent) 10%, transparent);\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.markdown-content :global(hr) {\n\t\tborder: none;\n\t\tborder-top: 1px solid var(--border-color-primary);\n\t\tmargin: 12px 0;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Model3D.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount } from \"svelte\";\n\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: string | null;\n\t}\n\n\tlet { label, value }: Props = $props();\n\n\tlet filename = $derived(value ? value.split('/').pop() || 'model.glb' : '');\n\n\tlet containerEl: HTMLDivElement;\n\tlet canvas: HTMLCanvasElement;\n\tlet viewer: any = $state(null);\n\tlet loading = $state(false);\n\tlet modelLoading = $state(false);\n\tlet error = $state<string | null>(null);\n\tlet currentModelUrl = $state<string | null>(null);\n\tlet mounted = $state(false);\n\tlet isFullscreen = $state(false);\n\n\tonMount(() => {\n\t\tmounted = true;\n\n\t\treturn () => {\n\t\t\tmounted = false;\n\t\t\tif (viewer) {\n\t\t\t\tviewer.dispose();\n\t\t\t\tviewer = null;\n\t\t\t}\n\t\t};\n\t});\n\n\t$effect(() => {\n\t\tif (canvas) {\n\t\t\tconst originalFocus = canvas.focus.bind(canvas);\n\t\t\tcanvas.focus = (options?: FocusOptions) => {\n\t\t\t\toriginalFocus({ ...options, preventScroll: true });\n\t\t\t};\n\t\t}\n\t});\n\n\t$effect(() => {\n\t\tif (!mounted || !canvas) return;\n\t\t\n\t\tif (value && !viewer && !loading) {\n\t\t\tinitViewer();\n\t\t} else if (value && viewer && value !== currentModelUrl && !modelLoading) {\n\t\t\tloadModel(value);\n\t\t} else if (!value && viewer && currentModelUrl) {\n\t\t\tviewer.resetModel();\n\t\t\tcurrentModelUrl = null;\n\t\t}\n\t});\n\n\tasync function initViewer() {\n\t\tif (loading || !canvas) return;\n\t\tloading = true;\n\t\terror = null;\n\n\t\ttry {\n\t\t\tconst BABYLON_VIEWER = await import(\"@babylonjs/viewer\");\n\t\t\t\n\t\t\tif (!mounted) return;\n\t\t\t\n\t\t\tconst createdViewer = await BABYLON_VIEWER.CreateViewerForCanvas(canvas, {\n\t\t\t\tclearColor: [0.1, 0.1, 0.1, 1],\n\t\t\t\tuseRightHandedSystem: true,\n\t\t\t\tanimationAutoPlay: true,\n\t\t\t\tcameraAutoOrbit: { enabled: false }\n\t\t\t});\n\t\t\t\n\t\t\tif (!mounted) {\n\t\t\t\tcreatedViewer.dispose();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t\n\t\t\tviewer = createdViewer;\n\t\t\tloading = false;\n\t\t\t\n\t\t\tif (value) {\n\t\t\t\tawait loadModel(value);\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tif (!mounted) return;\n\t\t\terror = e instanceof Error ? e.message : 'Failed to initialize 3D viewer';\n\t\t\tconsole.error('Failed to initialize Babylon.js viewer:', e);\n\t\t\tloading = false;\n\t\t}\n\t}\n\n\tasync function loadModel(url: string) {\n\t\tif (!viewer || modelLoading) return;\n\t\t\n\t\tmodelLoading = true;\n\t\terror = null;\n\t\t\n\t\ttry {\n\t\t\tif (currentModelUrl) {\n\t\t\t\tviewer.resetModel();\n\t\t\t}\n\t\t\t\n\t\t\tawait viewer.loadModel(url, {\n\t\t\t\tpluginOptions: {\n\t\t\t\t\tobj: {\n\t\t\t\t\t\timportVertexColors: true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t\t\n\t\t\tif (mounted) {\n\t\t\t\tcurrentModelUrl = url;\n\t\t\t\tviewer.resetCamera();\n\t\t\t}\n\t\t} catch (e) {\n\t\t\tif (!mounted) return;\n\t\t\terror = e instanceof Error ? e.message : 'Failed to load model';\n\t\t\tconsole.error('Failed to load 3D model:', e);\n\t\t} finally {\n\t\t\tif (mounted) {\n\t\t\t\tmodelLoading = false;\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction downloadModel() {\n\t\tif (!value) return;\n\t\tconst link = document.createElement('a');\n\t\tlink.href = value;\n\t\tlink.download = filename;\n\t\tdocument.body.appendChild(link);\n\t\tlink.click();\n\t\tdocument.body.removeChild(link);\n\t}\n\n\tfunction resetCamera() {\n\t\tif (viewer) {\n\t\t\tviewer.resetCamera();\n\t\t}\n\t}\n\t\n\tfunction retry() {\n\t\terror = null;\n\t\tcurrentModelUrl = null;\n\t\tif (viewer) {\n\t\t\tloadModel(value!);\n\t\t} else {\n\t\t\tinitViewer();\n\t\t}\n\t}\n\n\tfunction openFullscreen() {\n\t\tif (!containerEl) return;\n\t\tif (containerEl.requestFullscreen) {\n\t\t\tcontainerEl.requestFullscreen();\n\t\t} else if ((containerEl as any).webkitRequestFullscreen) {\n\t\t\t(containerEl as any).webkitRequestFullscreen();\n\t\t}\n\t}\n\n\tfunction handleFullscreenChange() {\n\t\tisFullscreen = !!document.fullscreenElement;\n\t}\n</script>\n\n<svelte:document onfullscreenchange={handleFullscreenChange} />\n\n<div class=\"gr-model3d-wrap\" class:fullscreen={isFullscreen} bind:this={containerEl}>\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t{#if value}\n\t\t\t<div class=\"header-actions\">\n\t\t\t\t<button class=\"action-btn\" onclick={resetCamera} title=\"Reset camera\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/>\n\t\t\t\t\t\t<path d=\"M3 3v5h5\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"View fullscreen\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={downloadModel} title=\"Download\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"7 10 12 15 17 10\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n\n\t{#if value}\n\t\t<div class=\"canvas-container\">\n\t\t\t{#if loading || modelLoading}\n\t\t\t\t<div class=\"loading-overlay\">\n\t\t\t\t\t<div class=\"spinner\"></div>\n\t\t\t\t\t<span>{loading ? 'Loading 3D viewer...' : 'Loading model...'}</span>\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t\t{#if error && !loading && !modelLoading}\n\t\t\t\t<div class=\"error-overlay\">\n\t\t\t\t\t<span class=\"error-icon\">⚠️</span>\n\t\t\t\t\t<span class=\"error-text\">{error}</span>\n\t\t\t\t\t<button class=\"retry-btn\" onclick={retry}>Retry</button>\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t\t<canvas bind:this={canvas} tabindex=\"-1\"></canvas>\n\t\t</div>\n\t\t<div class=\"model-footer\">\n\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" class=\"model-icon\">\n\t\t\t\t<path d=\"M12 2L2 7l10 5 10-5-10-5z\"/>\n\t\t\t\t<path d=\"M2 17l10 5 10-5\"/>\n\t\t\t\t<path d=\"M2 12l10 5 10-5\"/>\n\t\t\t</svg>\n\t\t\t<span class=\"model-name\">{filename}</span>\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No model</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-model3d-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t}\n\n\t.gr-model3d-wrap.fullscreen {\n\t\tposition: fixed;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\twidth: 100vw;\n\t\theight: 100vh;\n\t\tz-index: 9999;\n\t\tborder-radius: 0;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px 10px;\n\t\tborder-bottom: 1px solid var(--border-color-primary);\n\t}\n\n\t.gr-model3d-wrap.fullscreen .gr-header {\n\t\tpadding: 12px 20px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.gr-model3d-wrap.fullscreen .gr-label {\n\t\tfont-size: 14px;\n\t}\n\n\t.header-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .header-actions {\n\t\tgap: 8px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .action-btn {\n\t\twidth: 32px;\n\t\theight: 32px;\n\t\tpadding: 6px;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.gr-model3d-wrap.fullscreen .action-btn svg {\n\t\twidth: 18px;\n\t\theight: 18px;\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 30%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--color-accent);\n\t}\n\n\t.canvas-container {\n\t\tposition: relative;\n\t\twidth: 100%;\n\t\theight: 200px;\n\t\tbackground: var(--body-background-fill);\n\t}\n\n\t.gr-model3d-wrap.fullscreen .canvas-container {\n\t\tflex: 1;\n\t\theight: auto;\n\t}\n\n\t.canvas-container canvas {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tdisplay: block;\n\t\toutline: none;\n\t}\n\n\t.canvas-container canvas:focus {\n\t\toutline: none;\n\t}\n\n\t.loading-overlay,\n\t.error-overlay {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbottom: 0;\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\tgap: 10px;\n\t\tbackground: color-mix(in srgb, var(--body-background-fill) 90%, transparent);\n\t\tcolor: var(--body-text-color-subdued);\n\t\tfont-size: 11px;\n\t\tz-index: 10;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .loading-overlay,\n\t.gr-model3d-wrap.fullscreen .error-overlay {\n\t\tfont-size: 16px;\n\t\tgap: 16px;\n\t}\n\n\t.spinner {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t\tborder: 2px solid var(--border-color-primary);\n\t\tborder-top-color: var(--color-accent);\n\t\tborder-radius: 50%;\n\t\tanimation: spin 1s linear infinite;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .spinner {\n\t\twidth: 40px;\n\t\theight: 40px;\n\t\tborder-width: 3px;\n\t}\n\n\t@keyframes spin {\n\t\tto { transform: rotate(360deg); }\n\t}\n\n\t.error-icon {\n\t\tfont-size: 20px;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .error-icon {\n\t\tfont-size: 32px;\n\t}\n\n\t.error-text {\n\t\tcolor: var(--error-text-color);\n\t\ttext-align: center;\n\t\tpadding: 0 10px;\n\t}\n\n\t.retry-btn {\n\t\tmargin-top: 5px;\n\t\tpadding: 4px 12px;\n\t\tborder: 1px solid var(--color-accent);\n\t\tbackground: transparent;\n\t\tcolor: var(--color-accent);\n\t\tborder-radius: 4px;\n\t\tfont-size: 10px;\n\t\tcursor: pointer;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .retry-btn {\n\t\tpadding: 8px 20px;\n\t\tfont-size: 14px;\n\t}\n\n\t.retry-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 20%, transparent);\n\t}\n\n\t.model-footer {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t\tpadding: 8px 10px;\n\t\tbackground: var(--input-background-fill);\n\t}\n\n\t.gr-model3d-wrap.fullscreen .model-footer {\n\t\tpadding: 12px 20px;\n\t}\n\n\t.model-icon {\n\t\twidth: 18px;\n\t\theight: 18px;\n\t\tcolor: var(--color-accent);\n\t\tflex-shrink: 0;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .model-icon {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t}\n\n\t.model-name {\n\t\tfont-size: 10px;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tword-break: break-all;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t\twhite-space: nowrap;\n\t}\n\n\t.gr-model3d-wrap.fullscreen .model-name {\n\t\tfont-size: 14px;\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 20px;\n\t\ttext-align: center;\n\t}\n</style>\n"
  },
  {
    "path": "daggr/frontend/src/components/Number.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: number | null;\n\t\tminimum?: number;\n\t\tmaximum?: number;\n\t\tstep?: number;\n\t\tplaceholder?: string;\n\t\tdisabled?: boolean;\n\t\tonchange?: (value: number | null) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tminimum, \n\t\tmaximum, \n\t\tstep = 1, \n\t\tplaceholder = '',\n\t\tdisabled = false,\n\t\tonchange \n\t}: Props = $props();\n\n\tfunction handleInput(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst newValue = target.value === '' ? null : parseFloat(target.value);\n\t\tif (newValue !== null && !isNaN(newValue)) {\n\t\t\tlet clamped = newValue;\n\t\t\tif (minimum !== undefined) clamped = Math.max(clamped, minimum);\n\t\t\tif (maximum !== undefined) clamped = Math.min(clamped, maximum);\n\t\t\tonchange?.(clamped);\n\t\t} else if (newValue === null) {\n\t\t\tonchange?.(null);\n\t\t}\n\t}\n\n\tfunction increment() {\n\t\tif (disabled) return;\n\t\tconst current = value ?? 0;\n\t\tlet newValue = current + step;\n\t\tif (maximum !== undefined) newValue = Math.min(newValue, maximum);\n\t\tonchange?.(newValue);\n\t}\n\n\tfunction decrement() {\n\t\tif (disabled) return;\n\t\tconst current = value ?? 0;\n\t\tlet newValue = current - step;\n\t\tif (minimum !== undefined) newValue = Math.max(newValue, minimum);\n\t\tonchange?.(newValue);\n\t}\n</script>\n\n<div class=\"gr-number-wrap\">\n\t<span class=\"gr-label\">{label}</span>\n\t<div class=\"input-container\">\n\t\t<button class=\"step-btn\" onclick={decrement} {disabled}>\n\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/>\n\t\t\t</svg>\n\t\t</button>\n\t\t<input\n\t\t\ttype=\"number\"\n\t\t\tclass=\"gr-input\"\n\t\t\tvalue={value ?? ''}\n\t\t\tmin={minimum}\n\t\t\tmax={maximum}\n\t\t\t{step}\n\t\t\t{placeholder}\n\t\t\t{disabled}\n\t\t\toninput={handleInput}\n\t\t/>\n\t\t<button class=\"step-btn\" onclick={increment} {disabled}>\n\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/>\n\t\t\t\t<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/>\n\t\t\t</svg>\n\t\t</button>\n\t</div>\n</div>\n\n<style>\n\t.gr-number-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 0;\n\t}\n\n\t.input-container {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tpadding: 4px 10px 8px;\n\t\tgap: 4px;\n\t}\n\n\t.step-btn {\n\t\twidth: 24px;\n\t\theight: 24px;\n\t\tpadding: 4px;\n\t\tborder: none;\n\t\tbackground: var(--input-background-fill);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.step-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.step-btn:hover:not(:disabled) {\n\t\tbackground: var(--background-fill-secondary);\n\t}\n\n\t.step-btn:hover:not(:disabled) svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.step-btn:disabled {\n\t\topacity: 0.5;\n\t\tcursor: not-allowed;\n\t}\n\n\t.gr-input {\n\t\tflex: 1;\n\t\tmin-width: 0;\n\t\tpadding: 4px 8px;\n\t\tfont-size: 11px;\n\t\tfont-family: 'SF Mono', Monaco, monospace;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\toutline: none;\n\t\ttext-align: center;\n\t}\n\n\t.gr-input:focus {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.gr-input::placeholder {\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.gr-input:disabled {\n\t\topacity: 0.7;\n\t\tcursor: not-allowed;\n\t}\n\n\t.gr-input::-webkit-inner-spin-button,\n\t.gr-input::-webkit-outer-spin-button {\n\t\t-webkit-appearance: none;\n\t\tmargin: 0;\n\t}\n\n\t.gr-input[type=\"number\"] {\n\t\t-moz-appearance: textfield;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Radio.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tchoices: [string, string | number][];\n\t\tvalue: string | number | null;\n\t\tdisabled?: boolean;\n\t\tonchange?: (value: string | number) => void;\n\t}\n\n\tlet { label, choices, value, disabled = false, onchange }: Props = $props();\n\n\tfunction selectChoice(internalValue: string | number) {\n\t\tif (disabled) return;\n\t\tonchange?.(internalValue);\n\t}\n</script>\n\n<div class=\"gr-radio-wrap\">\n\t<span class=\"gr-label\">{label}</span>\n\t<div class=\"choices\">\n\t\t{#each choices as [displayValue, internalValue]}\n\t\t\t<label class=\"choice\" class:disabled class:selected={value === internalValue}>\n\t\t\t\t<input\n\t\t\t\t\ttype=\"radio\"\n\t\t\t\t\tchecked={value === internalValue}\n\t\t\t\t\t{disabled}\n\t\t\t\t\tonchange={() => selectChoice(internalValue)}\n\t\t\t\t/>\n\t\t\t\t<span class=\"radio-mark\"></span>\n\t\t\t\t<span class=\"choice-label\">{displayValue}</span>\n\t\t\t</label>\n\t\t{/each}\n\t</div>\n</div>\n\n<style>\n\t.gr-radio-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 4px;\n\t}\n\n\t.choices {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t\tgap: 6px;\n\t\tpadding: 0 10px 8px;\n\t}\n\n\t.choice {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 6px;\n\t\tpadding: 4px 8px;\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\ttransition: all 0.15s;\n\t}\n\n\t.choice:hover:not(.disabled) {\n\t\tborder-color: var(--border-color-primary);\n\t\tbackground: var(--background-fill-secondary);\n\t}\n\n\t.choice.selected {\n\t\tbackground: color-mix(in srgb, var(--color-accent) 15%, transparent);\n\t\tborder-color: color-mix(in srgb, var(--color-accent) 40%, transparent);\n\t}\n\n\t.choice.disabled {\n\t\tcursor: not-allowed;\n\t\topacity: 0.6;\n\t}\n\n\t.choice input {\n\t\tposition: absolute;\n\t\topacity: 0;\n\t\tcursor: pointer;\n\t\theight: 0;\n\t\twidth: 0;\n\t}\n\n\t.radio-mark {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tbackground: var(--checkbox-background-color);\n\t\tborder: 1px solid var(--checkbox-border-color);\n\t\tborder-radius: 50%;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: all 0.15s;\n\t\tflex-shrink: 0;\n\t}\n\n\t.choice input:checked ~ .radio-mark {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.radio-mark::after {\n\t\tcontent: '';\n\t\tdisplay: none;\n\t\twidth: 6px;\n\t\theight: 6px;\n\t\tbackground: var(--color-accent);\n\t\tborder-radius: 50%;\n\t}\n\n\t.choice input:checked ~ .radio-mark::after {\n\t\tdisplay: block;\n\t}\n\n\t.choice-label {\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Slider.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: number;\n\t\tmin?: number;\n\t\tmax?: number;\n\t\tstep?: number;\n\t\tdisabled?: boolean;\n\t\tonchange?: (value: number) => void;\n\t}\n\n\tlet { \n\t\tlabel, \n\t\tvalue, \n\t\tmin = 0, \n\t\tmax = 100, \n\t\tstep = 1, \n\t\tdisabled = false,\n\t\tonchange \n\t}: Props = $props();\n\n\tlet rangeEl: HTMLInputElement | null = $state(null);\n\n\tlet percentage = $derived(((value - min) / (max - min)) * 100);\n\n\t$effect(() => {\n\t\tif (rangeEl) {\n\t\t\trangeEl.style.setProperty('--range-progress', `${percentage}%`);\n\t\t}\n\t});\n\n\tfunction handleRangeInput(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tonchange?.(parseFloat(target.value));\n\t}\n\n\tfunction handleNumberInput(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tlet newValue = parseFloat(target.value);\n\t\tif (!isNaN(newValue)) {\n\t\t\tnewValue = Math.min(Math.max(newValue, min), max);\n\t\t\tonchange?.(newValue);\n\t\t}\n\t}\n</script>\n\n<div class=\"gr-slider-wrap\">\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<input\n\t\t\ttype=\"number\"\n\t\t\tclass=\"number-input\"\n\t\t\t{value}\n\t\t\tmin={min}\n\t\t\tmax={max}\n\t\t\t{step}\n\t\t\t{disabled}\n\t\t\toninput={handleNumberInput}\n\t\t/>\n\t</div>\n\t<div class=\"slider-container\">\n\t\t<span class=\"min-value\">{min}</span>\n\t\t<input\n\t\t\tbind:this={rangeEl}\n\t\t\ttype=\"range\"\n\t\t\tclass=\"range-input\"\n\t\t\t{value}\n\t\t\tmin={min}\n\t\t\tmax={max}\n\t\t\t{step}\n\t\t\t{disabled}\n\t\t\toninput={handleRangeInput}\n\t\t/>\n\t\t<span class=\"max-value\">{max}</span>\n\t</div>\n</div>\n\n<style>\n\t.gr-slider-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\tpadding: 6px 10px 10px;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tmargin-bottom: 8px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.number-input {\n\t\twidth: 60px;\n\t\tpadding: 3px 6px;\n\t\tbackground: var(--input-background-fill);\n\t\tborder: 1px solid var(--input-border-color);\n\t\tborder-radius: 4px;\n\t\tfont-size: 11px;\n\t\tcolor: var(--body-text-color);\n\t\ttext-align: center;\n\t\toutline: none;\n\t}\n\n\t.number-input:focus {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.number-input:disabled {\n\t\topacity: 0.6;\n\t\tcursor: not-allowed;\n\t}\n\n\t.slider-container {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 8px;\n\t}\n\n\t.min-value, .max-value {\n\t\tfont-size: 10px;\n\t\tcolor: var(--neutral-500);\n\t\tmin-width: 24px;\n\t}\n\n\t.min-value {\n\t\ttext-align: right;\n\t}\n\n\t.max-value {\n\t\ttext-align: left;\n\t}\n\n\t.range-input {\n\t\tflex: 1;\n\t\t-webkit-appearance: none;\n\t\tappearance: none;\n\t\theight: 4px;\n\t\tbackground: linear-gradient(\n\t\t\tto right,\n\t\t\tvar(--color-accent) var(--range-progress, 0%),\n\t\t\tvar(--border-color-primary) var(--range-progress, 0%)\n\t\t);\n\t\tborder-radius: 2px;\n\t\toutline: none;\n\t\tcursor: pointer;\n\t}\n\n\t.range-input:disabled {\n\t\topacity: 0.6;\n\t\tcursor: not-allowed;\n\t}\n\n\t.range-input::-webkit-slider-thumb {\n\t\t-webkit-appearance: none;\n\t\tappearance: none;\n\t\twidth: 14px;\n\t\theight: 14px;\n\t\tbackground: var(--body-text-color);\n\t\tborder-radius: 50%;\n\t\tcursor: pointer;\n\t\tbox-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n\t\ttransition: transform 0.1s;\n\t}\n\n\t.range-input::-webkit-slider-thumb:hover {\n\t\ttransform: scale(1.1);\n\t}\n\n\t.range-input::-moz-range-thumb {\n\t\twidth: 14px;\n\t\theight: 14px;\n\t\tbackground: var(--body-text-color);\n\t\tborder: none;\n\t\tborder-radius: 50%;\n\t\tcursor: pointer;\n\t\tbox-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n\t}\n\n\t.range-input::-moz-range-progress {\n\t\tbackground: var(--color-accent);\n\t\theight: 4px;\n\t\tborder-radius: 2px;\n\t}\n\n\t.range-input::-moz-range-track {\n\t\tbackground: var(--border-color-primary);\n\t\theight: 4px;\n\t\tborder-radius: 2px;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Textbox.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tplaceholder?: string;\n\t\tlines?: number;\n\t\tdisabled?: boolean;\n\t\tvalue: any;\n\t\toninput?: (value: string) => void;\n\t}\n\n\tlet { label, placeholder = '', lines = 1, disabled = false, value, oninput }: Props = $props();\n\n\tfunction handleInput(e: Event) {\n\t\tconst target = e.target as HTMLInputElement | HTMLTextAreaElement;\n\t\toninput?.(target.value);\n\t}\n</script>\n\n<div class=\"gr-textbox-wrap\">\n\t<span class=\"gr-label\">{label}</span>\n\t{#if lines > 1}\n\t\t<textarea\n\t\t\tclass=\"gr-input\"\n\t\t\t{placeholder}\n\t\t\trows={lines}\n\t\t\t{disabled}\n\t\t\t{value}\n\t\t\toninput={handleInput}\n\t\t></textarea>\n\t{:else}\n\t\t<input\n\t\t\ttype=\"text\"\n\t\t\tclass=\"gr-input\"\n\t\t\t{placeholder}\n\t\t\t{disabled}\n\t\t\t{value}\n\t\t\toninput={handleInput}\n\t\t/>\n\t{/if}\n</div>\n\n<style>\n\t.gr-textbox-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-label {\n\t\tdisplay: block;\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding: 6px 10px 0;\n\t}\n\n\t.gr-input {\n\t\twidth: 100%;\n\t\tpadding: 4px 10px 8px;\n\t\tfont-size: 11px;\n\t\tfont-family: inherit;\n\t\tcolor: var(--body-text-color);\n\t\tbackground: transparent;\n\t\tborder: none;\n\t\toutline: none;\n\t\tbox-sizing: border-box;\n\t}\n\n\t.gr-input::placeholder {\n\t\tcolor: var(--input-placeholder-color);\n\t}\n\n\t.gr-textbox-wrap:focus-within {\n\t\tborder-color: var(--color-accent);\n\t}\n\n\t.gr-input:disabled {\n\t\topacity: 0.7;\n\t\tcursor: not-allowed;\n\t}\n\n\ttextarea.gr-input {\n\t\tresize: none;\n\t\tmin-height: 36px;\n\t\tline-height: 1.4;\n\t}\n</style>\n\n"
  },
  {
    "path": "daggr/frontend/src/components/Video.svelte",
    "content": "<script lang=\"ts\">\n\tinterface Props {\n\t\tlabel: string;\n\t\tvalue: any;\n\t\teditable?: boolean;\n\t\tautoplay?: boolean;\n\t\tloop?: boolean;\n\t\tonchange?: (value: any) => void;\n\t}\n\n\tlet { label, value, editable = true, autoplay = false, loop = false, onchange }: Props = $props();\n\n\tlet videoEl: HTMLVideoElement | null = $state(null);\n\tlet previewVideoEl: HTMLVideoElement | null = $state(null);\n\tlet fileInputEl: HTMLInputElement | null = $state(null);\n\tlet isRecording = $state(false);\n\tlet mediaRecorder: MediaRecorder | null = $state(null);\n\tlet recordedChunks: Blob[] = $state([]);\n\tlet stream: MediaStream | null = $state(null);\n\n\t$effect(() => {\n\t\tif (previewVideoEl && stream) {\n\t\t\tpreviewVideoEl.srcObject = stream;\n\t\t}\n\t});\n\n\tlet src = $derived.by(() => {\n\t\tif (!value) return null;\n\t\tif (typeof value === 'string') return value;\n\t\tif (value.url) return value.url;\n\t\tif (value instanceof Blob) return URL.createObjectURL(value);\n\t\treturn null;\n\t});\n\n\tfunction triggerUpload() {\n\t\tfileInputEl?.click();\n\t}\n\n\tfunction handleFileSelect(e: Event) {\n\t\tconst target = e.target as HTMLInputElement;\n\t\tconst file = target.files?.[0];\n\t\tif (file) {\n\t\t\tonchange?.(file);\n\t\t}\n\t\ttarget.value = '';\n\t}\n\n\tasync function startRecording() {\n\t\ttry {\n\t\t\tstream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });\n\t\t\tmediaRecorder = new MediaRecorder(stream);\n\t\t\trecordedChunks = [];\n\n\t\t\tmediaRecorder.ondataavailable = (e) => {\n\t\t\t\tif (e.data.size > 0) {\n\t\t\t\t\trecordedChunks.push(e.data);\n\t\t\t\t}\n\t\t\t};\n\n\t\t\tmediaRecorder.onstop = () => {\n\t\t\t\tconst blob = new Blob(recordedChunks, { type: 'video/webm' });\n\t\t\t\tonchange?.(blob);\n\t\t\t\tstopStream();\n\t\t\t};\n\n\t\t\tmediaRecorder.start();\n\t\t\tisRecording = true;\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to access camera:', e);\n\t\t}\n\t}\n\n\tfunction stopRecording() {\n\t\tif (mediaRecorder && mediaRecorder.state !== 'inactive') {\n\t\t\tmediaRecorder.stop();\n\t\t}\n\t\tisRecording = false;\n\t}\n\n\tfunction stopStream() {\n\t\tif (stream) {\n\t\t\tstream.getTracks().forEach(track => track.stop());\n\t\t\tstream = null;\n\t\t}\n\t}\n\n\tfunction clearVideo() {\n\t\tonchange?.(null);\n\t}\n\n\tasync function downloadVideo() {\n\t\tif (!src) return;\n\t\ttry {\n\t\t\tconst response = await fetch(src);\n\t\t\tconst blob = await response.blob();\n\t\t\tconst blobUrl = URL.createObjectURL(blob);\n\t\t\tconst link = document.createElement('a');\n\t\t\tlink.href = blobUrl;\n\t\t\tconst ext = blob.type.split('/')[1]?.split(';')[0] || 'webm';\n\t\t\tlink.download = `${label || 'video'}.${ext}`;\n\t\t\tdocument.body.appendChild(link);\n\t\t\tlink.click();\n\t\t\tdocument.body.removeChild(link);\n\t\t\tURL.revokeObjectURL(blobUrl);\n\t\t} catch (e) {\n\t\t\tconsole.error('Failed to download video:', e);\n\t\t}\n\t}\n\n\tfunction openFullscreen() {\n\t\tif (videoEl) {\n\t\t\tif (videoEl.requestFullscreen) {\n\t\t\t\tvideoEl.requestFullscreen();\n\t\t\t}\n\t\t}\n\t}\n</script>\n\n<div class=\"gr-video-wrap\">\n\t<input\n\t\tbind:this={fileInputEl}\n\t\ttype=\"file\"\n\t\taccept=\"video/*\"\n\t\tstyle=\"display: none\"\n\t\tonchange={handleFileSelect}\n\t/>\n\n\t<div class=\"gr-header\">\n\t\t<span class=\"gr-label\">{label}</span>\n\t\t<div class=\"video-actions\">\n\t\t\t{#if isRecording}\n\t\t\t\t<button class=\"action-btn recording\" onclick={stopRecording} title=\"Stop recording\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n\t\t\t\t\t\t<rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"2\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{:else if src}\n\t\t\t\t<button class=\"action-btn\" onclick={openFullscreen} title=\"Fullscreen\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={downloadVideo} title=\"Download\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"7 10 12 15 17 10\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={clearVideo} title=\"Clear\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/>\n\t\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{:else if editable}\n\t\t\t\t<button class=\"action-btn\" onclick={triggerUpload} title=\"Upload video\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/>\n\t\t\t\t\t\t<polyline points=\"17 8 12 3 7 8\"/>\n\t\t\t\t\t\t<line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t\t<button class=\"action-btn\" onclick={startRecording} title=\"Record video\">\n\t\t\t\t\t<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n\t\t\t\t\t\t<polygon points=\"23 7 16 12 23 17 23 7\"/>\n\t\t\t\t\t\t<rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\" ry=\"2\"/>\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n\n\t{#if isRecording && stream}\n\t\t<div class=\"video-container recording-preview\">\n\t\t\t<!-- svelte-ignore a11y_media_has_caption -->\n\t\t\t<video autoplay muted playsinline bind:this={previewVideoEl}></video>\n\t\t\t<div class=\"recording-indicator\">\n\t\t\t\t<span class=\"rec-dot\"></span>\n\t\t\t\t<span>REC</span>\n\t\t\t</div>\n\t\t</div>\n\t{:else if src}\n\t\t<div class=\"video-container\">\n\t\t\t<!-- svelte-ignore a11y_media_has_caption -->\n\t\t\t<video\n\t\t\t\tbind:this={videoEl}\n\t\t\t\t{src}\n\t\t\t\t{autoplay}\n\t\t\t\t{loop}\n\t\t\t\tcontrols\n\t\t\t\tplaysinline\n\t\t\t></video>\n\t\t</div>\n\t{:else}\n\t\t<div class=\"gr-empty\">No video</div>\n\t{/if}\n</div>\n\n<style>\n\t.gr-video-wrap {\n\t\tbackground: var(--block-background-fill);\n\t\tborder: 1px solid var(--border-color-primary);\n\t\tborder-radius: 6px;\n\t\toverflow: hidden;\n\t}\n\n\t.gr-header {\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: space-between;\n\t\tpadding: 6px;\n\t}\n\n\t.gr-label {\n\t\tfont-size: 10px;\n\t\tfont-weight: 400;\n\t\tcolor: var(--body-text-color-subdued);\n\t\tpadding-left: 4px;\n\t}\n\n\t.video-actions {\n\t\tdisplay: flex;\n\t\tgap: 4px;\n\t}\n\n\t.action-btn {\n\t\twidth: 20px;\n\t\theight: 20px;\n\t\tpadding: 3px;\n\t\tborder: none;\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 8%, transparent);\n\t\tborder-radius: 4px;\n\t\tcursor: pointer;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\ttransition: background 0.15s;\n\t}\n\n\t.action-btn svg {\n\t\twidth: 12px;\n\t\theight: 12px;\n\t\tcolor: var(--body-text-color-subdued);\n\t}\n\n\t.action-btn:hover {\n\t\tbackground: color-mix(in srgb, var(--body-text-color) 15%, transparent);\n\t}\n\n\t.action-btn:hover svg {\n\t\tcolor: var(--body-text-color);\n\t}\n\n\t.action-btn.recording {\n\t\tbackground: var(--error-border-color);\n\t\tanimation: pulse-recording 1.5s ease-in-out infinite;\n\t}\n\n\t.action-btn.recording svg {\n\t\tcolor: var(--button-primary-text-color);\n\t}\n\n\t@keyframes pulse-recording {\n\t\t0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--error-border-color) 40%, transparent); }\n\t\t50% { box-shadow: 0 0 0 4px transparent; }\n\t}\n\n\t.video-container {\n\t\tpadding: 0 6px 6px;\n\t\tposition: relative;\n\t}\n\n\t.video-container video {\n\t\twidth: 100%;\n\t\tmax-height: 150px;\n\t\tborder-radius: 4px;\n\t\tbackground: var(--body-background-fill);\n\t}\n\n\t.recording-preview .recording-indicator {\n\t\tposition: absolute;\n\t\ttop: 10px;\n\t\tleft: 16px;\n\t\tdisplay: flex;\n\t\talign-items: center;\n\t\tgap: 4px;\n\t\tbackground: rgba(0, 0, 0, 0.6);\n\t\tpadding: 2px 6px;\n\t\tborder-radius: 3px;\n\t\tfont-size: 10px;\n\t\tcolor: var(--error-border-color);\n\t\tfont-weight: 600;\n\t}\n\n\t.rec-dot {\n\t\twidth: 6px;\n\t\theight: 6px;\n\t\tbackground: var(--error-border-color);\n\t\tborder-radius: 50%;\n\t\tanimation: blink 1s ease-in-out infinite;\n\t}\n\n\t@keyframes blink {\n\t\t0%, 100% { opacity: 1; }\n\t\t50% { opacity: 0.3; }\n\t}\n\n\t.gr-empty {\n\t\tfont-size: 11px;\n\t\tcolor: var(--input-placeholder-color);\n\t\tfont-style: italic;\n\t\tpadding: 6px;\n\t\ttext-align: center;\n\t}\n</style>\n\n\n"
  },
  {
    "path": "daggr/frontend/src/components/index.ts",
    "content": "export { default as AudioPlayer } from './AudioPlayer.svelte';\nexport { default as Audio } from './Audio.svelte';\nexport { default as EmbeddedComponent } from './EmbeddedComponent.svelte';\nexport { default as MapItemsSection } from './MapItemsSection.svelte';\nexport { default as ItemListSection } from './ItemListSection.svelte';\nexport { default as Textbox } from './Textbox.svelte';\nexport { default as Image } from './Image.svelte';\nexport { default as Checkbox } from './Checkbox.svelte';\nexport { default as CheckboxGroup } from './CheckboxGroup.svelte';\nexport { default as Radio } from './Radio.svelte';\nexport { default as Dropdown } from './Dropdown.svelte';\nexport { default as Slider } from './Slider.svelte';\nexport { default as Number } from './Number.svelte';\nexport { default as Button } from './Button.svelte';\nexport { default as Video } from './Video.svelte';\nexport { default as File } from './File.svelte';\nexport { default as Dataframe } from './Dataframe.svelte';\nexport { default as Dialogue } from './Dialogue.svelte';\nexport { default as Gallery } from './Gallery.svelte';\nexport { default as Json } from './Json.svelte';\nexport { default as ColorPicker } from './ColorPicker.svelte';\nexport { default as Markdown } from './Markdown.svelte';\nexport { default as Html } from './Html.svelte';\nexport { default as Code } from './Code.svelte';\nexport { default as Label } from './Label.svelte';\nexport { default as HighlightedText } from './HighlightedText.svelte';\nexport { default as ImageSlider } from './ImageSlider.svelte';\nexport { default as Model3D } from './Model3D.svelte';\n"
  },
  {
    "path": "daggr/frontend/src/main.ts",
    "content": "import App from './App.svelte'\nimport { mount } from 'svelte'\n\nconst app = mount(App, {\n  target: document.getElementById('app')!\n})\n\nexport default app\n\n"
  },
  {
    "path": "daggr/frontend/src/types.ts",
    "content": "export interface Port {\n\tname: string;\n\thistory_count?: number;\n}\n\nexport interface GradioComponentData {\n\tcomponent: string;\n\ttype: string;\n\tport_name: string;\n\tprops: Record<string, any>;\n\tvalue?: any;\n}\n\nexport interface MapItem {\n\tindex: number;\n\tpreview: string;\n\toutput: string | null;\n\tis_audio_output: boolean;\n\tstatus?: string;\n}\n\nexport interface ItemListItem {\n\tindex: number;\n\tfields: Record<string, any>;\n}\n\nexport interface NodeVariant {\n\tname: string;\n\tinput_components: GradioComponentData[];\n\toutput_components: GradioComponentData[];\n}\n\nexport interface GraphNode {\n\tid: string;\n\tname: string;\n\ttype: string;\n\turl?: string;\n\tinputs: Port[];\n\toutputs: string[];\n\tinput_components?: GradioComponentData[];\n\toutput_components?: GradioComponentData[];\n\tx: number;\n\ty: number;\n\tstatus: string;\n\tis_output_node: boolean;\n\tis_input_node: boolean;\n\tis_map_node?: boolean;\n\tmap_items?: MapItem[];\n\tmap_item_count?: number;\n\titem_list_schema?: GradioComponentData[];\n\titem_list_items?: ItemListItem[];\n\tvariants?: NodeVariant[];\n\tselected_variant?: number;\n}\n\nexport interface GraphEdge {\n\tid: string;\n\tfrom_node: string;\n\tfrom_port: string;\n\tto_node: string;\n\tto_port: string;\n\tis_scattered?: boolean;\n\tis_gathered?: boolean;\n}\n\nexport interface CanvasData {\n\tname: string;\n\tnodes: GraphNode[];\n\tedges: GraphEdge[];\n\tinputs?: Record<string, Record<string, string>>;\n\tsession_id?: string;\n\trun_id?: string;\n\tcompleted_node?: string;\n}\n\n"
  },
  {
    "path": "daggr/frontend/svelte.config.js",
    "content": "import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\nexport default {\n  preprocess: vitePreprocess()\n}\n\n"
  },
  {
    "path": "daggr/frontend/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport { svelte } from '@sveltejs/vite-plugin-svelte'\n\nexport default defineConfig({\n  plugins: [svelte()],\n  build: {\n    outDir: 'dist',\n    emptyOutDir: true,\n  },\n  server: {\n    port: 5173,\n    proxy: {\n      '/api': 'http://127.0.0.1:7860',\n      '/ws': {\n        target: 'ws://127.0.0.1:7860',\n        ws: true\n      }\n    }\n  }\n})\n\n"
  },
  {
    "path": "daggr/graph.py",
    "content": "\"\"\"Graph module for daggr.\n\nA Graph represents a directed acyclic graph (DAG) of nodes that can be\nexecuted to process data through a pipeline.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport itertools\nimport os\nimport re\nimport sys\nimport threading\nfrom collections.abc import Sequence\nfrom typing import TYPE_CHECKING, Any\n\nimport networkx as nx\n\nfrom daggr._utils import suggest_similar\nfrom daggr.edge import Edge\nfrom daggr.local_space import prepare_local_node\nfrom daggr.node import ChoiceNode, GradioNode, InferenceNode, Node\nfrom daggr.port import Port\n\nif TYPE_CHECKING:\n    from gradio.themes import ThemeClass as Theme\n\n\ndef _parse_space_id(src: str) -> str | None:\n    if src.startswith(\"http://\") or src.startswith(\"https://\"):\n        match = re.match(r\"https?://huggingface\\.co/spaces/([^/]+/[^/?#]+)\", src)\n        if match:\n            return match.group(1)\n        return None\n    if \"/\" in src:\n        return src\n    return None\n\n\ndef _get_dependency_id(node) -> tuple[str | None, str]:\n    if isinstance(node, GradioNode):\n        space_id = _parse_space_id(node._src)\n        return space_id, \"space\"\n    elif isinstance(node, InferenceNode):\n        return node._model_name_for_hub, \"model\"\n    return None, \"\"\n\n\ndef _fetch_current_sha(dep_id: str, dep_type: str) -> str | None:\n    try:\n        if dep_type == \"space\":\n            from huggingface_hub import space_info\n\n            info = space_info(dep_id)\n            return info.sha\n        elif dep_type == \"model\":\n            from huggingface_hub import model_info\n\n            info = model_info(dep_id)\n            return info.sha\n    except Exception:\n        return None\n    return None\n\n\ndef _duplicate_space_at_revision(\n    space_id: str, revision: str, username: str\n) -> str | None:\n    try:\n        from huggingface_hub import (\n            create_repo,\n            snapshot_download,\n            upload_folder,\n        )\n\n        space_name = space_id.split(\"/\")[-1]\n        new_repo_id = f\"{username}/{space_name}\"\n\n        local_dir = snapshot_download(\n            repo_id=space_id,\n            repo_type=\"space\",\n            revision=revision,\n        )\n\n        create_repo(\n            repo_id=new_repo_id,\n            repo_type=\"space\",\n            space_sdk=\"gradio\",\n            exist_ok=True,\n        )\n\n        upload_folder(\n            repo_id=new_repo_id,\n            repo_type=\"space\",\n            folder_path=local_dir,\n        )\n\n        return new_repo_id\n    except Exception as e:\n        print(f\"  [daggr] Failed to duplicate Space: {e}\")\n        return None\n\n\ndef _prompt_dependency_changes(changed: list[dict]) -> None:\n    from daggr import _client_cache\n\n    is_tty = hasattr(sys.stdin, \"isatty\") and sys.stdin.isatty()\n\n    print(\"\\n  ⚠️  Upstream dependency changes detected:\\n\")\n    for item in changed:\n        print(\n            f\"    • {item['type']} '{item['id']}' (node: {item['node']._name})\\n\"\n            f\"      cached:  {item['cached_sha'][:12]}\\n\"\n            f\"      current: {item['current_sha'][:12]}\"\n        )\n    print()\n\n    if not is_tty:\n        for item in changed:\n            _client_cache.set_dependency_hash(item[\"id\"], item[\"current_sha\"])\n        print(\n            \"  [daggr] Non-interactive mode: auto-updated all hashes.\\n\"\n            \"  Set DAGGR_DEPENDENCY_CHECK=skip to suppress this warning.\\n\"\n        )\n        return\n\n    for item in changed:\n        is_space = item[\"type\"] == \"space\"\n        if is_space:\n            print(\n                f\"  How would you like to handle '{item['id']}'?\\n\"\n                f\"    [1] Duplicate the original version under your namespace (safer)\\n\"\n                f\"    [2] Update to the latest version\"\n            )\n        else:\n            print(\n                f\"  How would you like to handle '{item['id']}'?\\n\"\n                f\"    [1] Update to the latest version\"\n            )\n\n        try:\n            choice = input(\"  Choice [1]: \").strip() or \"1\"\n        except (EOFError, KeyboardInterrupt):\n            choice = \"1\"\n\n        if is_space and choice == \"1\":\n            username = _get_hf_username()\n            if username is None:\n                print(\n                    \"  [daggr] Not logged in to Hugging Face. \"\n                    \"Updating hash instead.\\n\"\n                    \"  Run `huggingface-cli login` to enable Space duplication.\"\n                )\n                _client_cache.set_dependency_hash(item[\"id\"], item[\"current_sha\"])\n            else:\n                print(\n                    f\"  [daggr] Duplicating '{item['id']}' at revision \"\n                    f\"{item['cached_sha'][:12]} under {username}/...\"\n                )\n                new_id = _duplicate_space_at_revision(\n                    item[\"id\"], item[\"cached_sha\"], username\n                )\n                if new_id:\n                    item[\"node\"]._src = new_id\n                    _client_cache.set_dependency_hash(new_id, item[\"cached_sha\"])\n                    print(\n                        f\"  [daggr] Duplicated → '{new_id}'. \"\n                        f\"Node now points to duplicated Space.\"\n                    )\n                else:\n                    print(\n                        \"  [daggr] Duplication failed (revision may have been \"\n                        \"squashed). Updating hash instead.\"\n                    )\n                    _client_cache.set_dependency_hash(item[\"id\"], item[\"current_sha\"])\n        else:\n            _client_cache.set_dependency_hash(item[\"id\"], item[\"current_sha\"])\n            print(f\"  [daggr] Updated hash for '{item['id']}'.\")\n\n    print()\n\n\ndef _get_hf_username() -> str | None:\n    try:\n        from huggingface_hub import get_token, whoami\n\n        token = get_token()\n        if not token:\n            return None\n        info = whoami(cache=True)\n        return info.get(\"name\")\n    except Exception:\n        return None\n\n\nclass _Spinner:\n    _CHARS = \"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\"\n\n    def __init__(self, message: str):\n        self._message = message\n        self._is_tty = hasattr(sys.stdout, \"isatty\") and sys.stdout.isatty()\n        if self._is_tty:\n            self._stop = threading.Event()\n            self._thread = threading.Thread(target=self._spin, daemon=True)\n            self._thread.start()\n\n    def _spin(self):\n        frames = itertools.cycle(self._CHARS)\n        while not self._stop.is_set():\n            sys.stdout.write(f\"\\r  {next(frames)} {self._message}\")\n            sys.stdout.flush()\n            self._stop.wait(0.08)\n\n    def _finish(self, symbol: str, suffix: str = \"\"):\n        line = f\"  {symbol} {self._message}\"\n        if suffix:\n            line += f\" — {suffix}\"\n        if self._is_tty:\n            self._stop.set()\n            self._thread.join()\n            sys.stdout.write(f\"\\r{line}\\033[K\\n\")\n        else:\n            sys.stdout.write(f\"{line}\\n\")\n        sys.stdout.flush()\n\n    def succeed(self, suffix: str = \"\"):\n        self._finish(\"✓\", suffix)\n\n    def warn(self, suffix: str = \"\"):\n        self._finish(\"⚠\", suffix)\n\n\ndef _get_node_display_label(node) -> str:\n    if isinstance(node, GradioNode):\n        label = node._src\n        if node._api_name:\n            label += f\" ({node._api_name})\"\n        return label\n    elif isinstance(node, InferenceNode):\n        return node._model_name_for_hub\n    return node._name\n\n\nclass Graph:\n    \"\"\"A directed acyclic graph (DAG) of nodes for data processing.\n\n    A Graph connects nodes together to form a pipeline. Data flows from entry\n    nodes (nodes with no inputs) through the graph to output nodes.\n\n    Example:\n        >>> from daggr import Graph, FnNode\n        >>> def step1(x): return {\"out\": x * 2}\n        >>> def step2(y): return {\"out\": y + 1}\n        >>> n1 = FnNode(step1)\n        >>> n2 = FnNode(step2, inputs={\"y\": n1.out})\n        >>> graph = Graph(\"My Pipeline\", nodes=[n2])\n        >>> graph.launch()\n    \"\"\"\n\n    def __init__(\n        self,\n        name: str,\n        nodes: Sequence[Node] | None = None,\n        persist_key: str | bool | None = None,\n    ):\n        \"\"\"Create a new Graph.\n\n        Args:\n            name: Display name for this graph shown in the UI.\n            nodes: Optional list of nodes to add to the graph.\n            persist_key: Unique key used to store this graph's data in the database.\n                         If not provided, derived from name by converting to lowercase\n                         and replacing spaces/special chars with underscores.\n                         Set to False to disable persistence entirely.\n                         Use a custom string to ensure persistence works correctly\n                         if you change the display name later.\n        \"\"\"\n        if not name or not isinstance(name, str):\n            raise ValueError(\n                \"Graph requires a 'name' parameter. \"\n                \"Example: Graph(name='My Podcast Generator', nodes=[...])\"\n            )\n        self.name = name\n        if persist_key is False:\n            self.persist_key = None\n        elif persist_key:\n            self.persist_key = persist_key\n        else:\n            self.persist_key = re.sub(r\"[^a-z0-9]+\", \"_\", name.lower()).strip(\"_\")\n        self.nodes: dict[str, Node] = {}\n        self._nx_graph = nx.DiGraph()\n        self._edges: list[Edge] = []\n\n        if nodes:\n            for node in nodes:\n                self.add(node)\n\n    def add(self, node: Node) -> Graph:\n        \"\"\"Add a node to the graph.\n\n        Also adds any upstream nodes connected via the node's port connections.\n\n        Args:\n            node: The node to add.\n\n        Returns:\n            self, for method chaining.\n        \"\"\"\n        self._add_node(node)\n        self._create_edges_from_port_connections(node)\n        return self\n\n    def edge(self, source: Port, target: Port) -> Graph:\n        \"\"\"Create an edge connecting two ports.\n\n        Args:\n            source: The source port (output of a node).\n            target: The target port (input of a node).\n\n        Returns:\n            self, for method chaining.\n\n        Raises:\n            ValueError: If the edge would create a cycle.\n        \"\"\"\n        edge = Edge(source, target)\n        self._add_edge(edge)\n        return self\n\n    def _add_node(self, node: Node) -> None:\n        if node._name in self.nodes:\n            if self.nodes[node._name] is not node:\n                raise ValueError(f\"Node with name '{node._name}' already exists\")\n            return\n        self.nodes[node._name] = node\n        self._nx_graph.add_node(node._name)\n\n    def _create_edges_from_port_connections(self, node: Node) -> None:\n        for target_port_name, source_port in node._port_connections.items():\n            source_node = source_port.node\n            source_port_name = source_port.name\n\n            if source_port_name not in source_node._output_ports:\n                available = set(source_node._output_ports)\n                suggestion = suggest_similar(source_port_name, available)\n                available_str = \", \".join(available) or \"(none)\"\n                msg = (\n                    f\"Output port '{source_port_name}' not found on node \"\n                    f\"'{source_node._name}'. Available outputs: {available_str}\"\n                )\n                if suggestion:\n                    msg += f\" Did you mean '{suggestion}'?\"\n                raise ValueError(msg)\n\n            is_new_node = source_node._name not in self.nodes\n            self._add_node(source_node)\n            if is_new_node:\n                self._create_edges_from_port_connections(source_node)\n            target_port = Port(node, target_port_name)\n            edge = Edge(source_port, target_port)\n            self._add_edge(edge)\n\n    def _add_edge(self, edge: Edge) -> None:\n        self._add_node(edge.source_node)\n        self._add_node(edge.target_node)\n\n        self._edges.append(edge)\n        self._nx_graph.add_edge(edge.source_node._name, edge.target_node._name)\n\n        if not nx.is_directed_acyclic_graph(self._nx_graph):\n            self._nx_graph.remove_edge(edge.source_node._name, edge.target_node._name)\n            self._edges.pop()\n            raise ValueError(\"Connection would create a cycle in the DAG\")\n\n    def get_entry_nodes(self) -> list[Node]:\n        \"\"\"Get all nodes with no incoming edges (entry points of the graph).\"\"\"\n        entry_nodes = []\n        for node_name in self.nodes:\n            if self._nx_graph.in_degree(node_name) == 0:\n                entry_nodes.append(self.nodes[node_name])\n        return entry_nodes\n\n    def get_execution_order(self) -> list[str]:\n        \"\"\"Get the topologically sorted order of node names for execution.\"\"\"\n        return list(nx.topological_sort(self._nx_graph))\n\n    def get_connections(self) -> list[tuple]:\n        \"\"\"Get all edges as tuples of (source_node, source_port, target_node, target_port).\"\"\"\n        return [edge.as_tuple() for edge in self._edges]\n\n    def _validate_edges(self) -> None:\n        errors = []\n        for edge in self._edges:\n            source_node = edge.source_node\n            target_node = edge.target_node\n            source_port = edge.source_port\n            target_port = edge.target_port\n\n            if source_port not in source_node._output_ports:\n                available = set(source_node._output_ports)\n                available_str = \", \".join(available) or \"(none)\"\n                suggestion = suggest_similar(source_port, available)\n                msg = (\n                    f\"Output port '{source_port}' not found on node \"\n                    f\"'{source_node._name}'. Available outputs: {available_str}\"\n                )\n                if suggestion:\n                    msg += f\" Did you mean '{suggestion}'?\"\n                errors.append(msg)\n\n            if target_port not in target_node._input_ports:\n                available = set(target_node._input_ports)\n                available_str = \", \".join(available) or \"(none)\"\n                suggestion = suggest_similar(target_port, available)\n                msg = (\n                    f\"Input port '{target_port}' not found on node \"\n                    f\"'{target_node._name}'. Available inputs: {available_str}\"\n                )\n                if suggestion:\n                    msg += f\" Did you mean '{suggestion}'?\"\n                errors.append(msg)\n\n        if errors:\n            raise ValueError(\"Invalid port connections:\\n  - \" + \"\\n  - \".join(errors))\n\n    def launch(\n        self,\n        host: str | None = None,\n        port: int | None = None,\n        share: bool | None = None,\n        open_browser: bool = True,\n        theme: Theme | str | None = None,\n        api_server: bool = True,\n        **kwargs,\n    ):\n        \"\"\"Launch the graph as an interactive web application.\n\n        Starts a web server that displays the graph and allows users to\n        execute nodes and view results.\n\n        Args:\n            host: Host to bind to. Defaults to GRADIO_SERVER_NAME env var,\n                or \"127.0.0.1\" if not set. Set to \"0.0.0.0\" to make\n                accessible on a network or when deploying to Hugging Face Spaces.\n            port: Port to bind to. Defaults to GRADIO_SERVER_PORT env var,\n                or 7860 if not set.\n            share: If True, create a public share link. Defaults to True in\n                Colab/Kaggle environments, False otherwise.\n            open_browser: If True, automatically open the app in the default\n                web browser. Defaults to True.\n            theme: A Gradio theme to use for styling. Can be a Gradio `Theme` instance,\n                a string name like \"default\", \"soft\", \"monochrome\", \"glass\",\n                or a Hub theme like \"gradio/seafoam\". Defaults to the Gradio\n                default theme.\n            api_server: If True, expose the programmatic API endpoints\n                (/api/call, /api/schema). Defaults to True.\n            **kwargs: Additional arguments passed to uvicorn.\n        \"\"\"\n        from daggr.server import DaggrServer\n\n        if host is None:\n            host = os.environ.get(\"GRADIO_SERVER_NAME\", \"127.0.0.1\")\n        if port is None:\n            port = int(os.environ.get(\"GRADIO_SERVER_PORT\", \"7860\"))\n\n        self._startup_display()\n        server = DaggrServer(self, theme=theme, api_server=api_server)\n        server.run(\n            host=host, port=port, share=share, open_browser=open_browser, **kwargs\n        )\n\n    def _prepare_local_nodes(self) -> None:\n        for node in self.nodes.values():\n            if isinstance(node, ChoiceNode):\n                for variant in node._variants:\n                    if isinstance(variant, GradioNode) and variant._run_locally:\n                        prepare_local_node(variant)\n            elif isinstance(node, GradioNode) and node._run_locally:\n                prepare_local_node(node)\n\n    def _check_dependency_hashes(self) -> None:\n        mode = os.environ.get(\"DAGGR_DEPENDENCY_CHECK\", \"\").lower()\n        if mode == \"skip\":\n            return\n\n        from daggr import _client_cache\n\n        nodes_to_check: list[GradioNode | InferenceNode] = []\n        for node in self.nodes.values():\n            if isinstance(node, ChoiceNode):\n                for variant in node._variants:\n                    if isinstance(variant, (GradioNode, InferenceNode)):\n                        nodes_to_check.append(variant)\n            elif isinstance(node, (GradioNode, InferenceNode)):\n                nodes_to_check.append(node)\n\n        if not nodes_to_check:\n            return\n\n        changed: list[dict[str, Any]] = []\n        for node in nodes_to_check:\n            dep_id, dep_type = _get_dependency_id(node)\n            if dep_id is None:\n                continue\n\n            current_sha = _fetch_current_sha(dep_id, dep_type)\n            if current_sha is None:\n                continue\n\n            cached_sha = _client_cache.get_dependency_hash(dep_id)\n            if cached_sha is None:\n                _client_cache.set_dependency_hash(dep_id, current_sha)\n            elif cached_sha != current_sha:\n                changed.append(\n                    {\n                        \"type\": dep_type,\n                        \"id\": dep_id,\n                        \"node\": node,\n                        \"cached_sha\": cached_sha,\n                        \"current_sha\": current_sha,\n                    }\n                )\n\n        if not changed:\n            return\n\n        if mode == \"update\":\n            for item in changed:\n                _client_cache.set_dependency_hash(item[\"id\"], item[\"current_sha\"])\n                print(\n                    f\"  [daggr] Auto-updated hash for {item['type']} \"\n                    f\"'{item['id']}' → {item['current_sha'][:12]}\"\n                )\n            return\n\n        if mode == \"error\":\n            descs = [\n                f\"  • {item['type']} '{item['id']}': \"\n                f\"{item['cached_sha'][:12]} → {item['current_sha'][:12]}\"\n                for item in changed\n            ]\n            raise RuntimeError(\n                \"Upstream dependencies have changed:\\n\"\n                + \"\\n\".join(descs)\n                + \"\\nSet DAGGR_DEPENDENCY_CHECK=update to accept changes.\"\n            )\n\n        _prompt_dependency_changes(changed)\n\n    def _startup_display(self) -> None:\n        mode = os.environ.get(\"DAGGR_DEPENDENCY_CHECK\", \"\").lower()\n        skip_hashes = mode == \"skip\"\n\n        node_count = len(self.nodes)\n        noun = \"node\" if node_count == 1 else \"nodes\"\n        print(f\"\\n  Launching Daggr ({self.name}) with {node_count} {noun}:\\n\")\n\n        from daggr import _client_cache\n\n        changed: list[dict[str, Any]] = []\n\n        def _check_hash(node):\n            dep_id, dep_type = _get_dependency_id(node)\n            if dep_id is None:\n                return None\n\n            current_sha = _fetch_current_sha(dep_id, dep_type)\n            if current_sha is None:\n                return None\n\n            cached_sha = _client_cache.get_dependency_hash(dep_id)\n            if cached_sha is None:\n                _client_cache.set_dependency_hash(dep_id, current_sha)\n                return (\"recorded\", f\"hash {current_sha[:7]} recorded\")\n            elif cached_sha == current_sha:\n                return (\"matches\", f\"hash {current_sha[:7]} matches\")\n            else:\n                changed.append(\n                    {\n                        \"type\": dep_type,\n                        \"id\": dep_id,\n                        \"node\": node,\n                        \"cached_sha\": cached_sha,\n                        \"current_sha\": current_sha,\n                    }\n                )\n                return (\"changed\", \"hash changed\")\n\n        for node in self.nodes.values():\n            if isinstance(node, ChoiceNode):\n                spinner = _Spinner(node._name)\n                for variant in node._variants:\n                    if isinstance(variant, GradioNode) and variant._run_locally:\n                        prepare_local_node(variant)\n                results = []\n                if not skip_hashes:\n                    for variant in node._variants:\n                        if isinstance(variant, (GradioNode, InferenceNode)):\n                            result = _check_hash(variant)\n                            if result:\n                                results.append(result)\n                if any(r[0] == \"changed\" for r in results):\n                    spinner.warn(\"hash changed\")\n                elif results:\n                    spinner.succeed(results[-1][1])\n                else:\n                    spinner.succeed()\n                continue\n\n            if isinstance(node, GradioNode) and node._run_locally:\n                prepare_local_node(node)\n\n            label = _get_node_display_label(node)\n\n            if isinstance(node, (GradioNode, InferenceNode)) and not skip_hashes:\n                spinner = _Spinner(label)\n                result = _check_hash(node)\n                if result and result[0] == \"changed\":\n                    spinner.warn(result[1])\n                elif result:\n                    spinner.succeed(result[1])\n                else:\n                    spinner.succeed()\n            else:\n                sys.stdout.write(f\"  ✓ {label}\\n\")\n                sys.stdout.flush()\n\n        print()\n\n        if not changed:\n            return\n\n        if mode == \"update\":\n            for item in changed:\n                _client_cache.set_dependency_hash(item[\"id\"], item[\"current_sha\"])\n                print(\n                    f\"  [daggr] Auto-updated hash for {item['type']} \"\n                    f\"'{item['id']}' → {item['current_sha'][:12]}\"\n                )\n            return\n\n        if mode == \"error\":\n            descs = [\n                f\"  • {item['type']} '{item['id']}': \"\n                f\"{item['cached_sha'][:12]} → {item['current_sha'][:12]}\"\n                for item in changed\n            ]\n            raise RuntimeError(\n                \"Upstream dependencies have changed:\\n\"\n                + \"\\n\".join(descs)\n                + \"\\nSet DAGGR_DEPENDENCY_CHECK=update to accept changes.\"\n            )\n\n        _prompt_dependency_changes(changed)\n\n    def get_subgraphs(self) -> list[set[str]]:\n        \"\"\"Get all weakly connected components of the graph.\n\n        Returns a list of sets, where each set contains the node names\n        belonging to a connected subgraph. If the graph is fully connected,\n        returns a single set with all node names.\n        \"\"\"\n        return [set(c) for c in nx.weakly_connected_components(self._nx_graph)]\n\n    def get_output_nodes(self) -> list[str]:\n        \"\"\"Get all nodes with no outgoing edges (output/leaf nodes).\"\"\"\n        return [\n            node_name\n            for node_name in self.nodes\n            if self._nx_graph.out_degree(node_name) == 0\n        ]\n\n    def get_api_schema(self) -> dict:\n        \"\"\"Get the API schema describing inputs and outputs for each subgraph.\n\n        Returns a dict with:\n        - subgraphs: list of subgraph info, each containing:\n          - id: subgraph identifier (e.g., \"main\" or \"subgraph_0\")\n          - inputs: list of {node, port, type, component} for each input\n          - outputs: list of {node, port, type, component} for each output\n        \"\"\"\n        subgraphs = self.get_subgraphs()\n        output_nodes = set(self.get_output_nodes())\n        result = {\"subgraphs\": []}\n\n        for idx, subgraph_nodes in enumerate(subgraphs):\n            subgraph_id = \"main\" if len(subgraphs) == 1 else f\"subgraph_{idx}\"\n\n            inputs = []\n            outputs = []\n\n            for node_name in subgraph_nodes:\n                node = self.nodes[node_name]\n\n                if isinstance(node, ChoiceNode):\n                    continue\n\n                if node._input_components:\n                    for port_name, comp in node._input_components.items():\n                        comp_type = self._get_component_type(comp)\n                        inputs.append(\n                            {\n                                \"node\": node_name,\n                                \"port\": port_name,\n                                \"type\": comp_type,\n                                \"id\": f\"{node_name}__{port_name}\".replace(\n                                    \" \", \"_\"\n                                ).replace(\"-\", \"_\"),\n                            }\n                        )\n\n                if node_name in output_nodes and node._output_components:\n                    for port_name, comp in node._output_components.items():\n                        if comp is None:\n                            continue\n                        comp_type = self._get_component_type(comp)\n                        outputs.append(\n                            {\n                                \"node\": node_name,\n                                \"port\": port_name,\n                                \"type\": comp_type,\n                            }\n                        )\n\n            result[\"subgraphs\"].append(\n                {\n                    \"id\": subgraph_id,\n                    \"inputs\": inputs,\n                    \"outputs\": outputs,\n                }\n            )\n\n        return result\n\n    def _get_component_type(self, component) -> str:\n        \"\"\"Get the type string for a Gradio component.\"\"\"\n        class_name = component.__class__.__name__\n        type_map = {\n            \"Audio\": \"audio\",\n            \"Textbox\": \"textbox\",\n            \"TextArea\": \"textarea\",\n            \"JSON\": \"json\",\n            \"Chatbot\": \"json\",\n            \"Image\": \"image\",\n            \"Number\": \"number\",\n            \"Markdown\": \"markdown\",\n            \"Text\": \"text\",\n            \"Dropdown\": \"dropdown\",\n            \"Video\": \"video\",\n            \"File\": \"file\",\n            \"Model3D\": \"model3d\",\n            \"Gallery\": \"gallery\",\n            \"Slider\": \"slider\",\n            \"Radio\": \"radio\",\n            \"Checkbox\": \"checkbox\",\n            \"CheckboxGroup\": \"checkboxgroup\",\n            \"ColorPicker\": \"colorpicker\",\n            \"Label\": \"label\",\n            \"HighlightedText\": \"highlightedtext\",\n            \"Code\": \"code\",\n            \"HTML\": \"html\",\n            \"Dataframe\": \"dataframe\",\n        }\n        return type_map.get(class_name, \"text\")\n\n    def __repr__(self):\n        return f\"Graph(name={self.name}, nodes={len(self.nodes)}, edges={len(self._edges)})\"\n"
  },
  {
    "path": "daggr/local_space.py",
    "content": "from __future__ import annotations\n\nimport atexit\nimport hashlib\nimport json\nimport os\nimport re\nimport select\nimport shutil\nimport socket\nimport subprocess\nimport sys\nimport time\nimport urllib.error\nimport urllib.request\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nif TYPE_CHECKING:\n    from daggr.node import GradioNode\n\nfrom daggr.state import get_daggr_cache_dir\n\n\ndef _get_spaces_cache_dir() -> Path:\n    return get_daggr_cache_dir() / \"spaces\"\n\n\ndef _get_logs_dir() -> Path:\n    return get_daggr_cache_dir() / \"logs\"\n\n\n_running_processes: dict[str, subprocess.Popen] = {}\n\n\ndef _get_space_dir(space_id: str) -> Path:\n    spaces_dir = _get_spaces_cache_dir()\n    parts = space_id.split(\"/\")\n    if len(parts) == 2:\n        owner, name = parts\n        return spaces_dir / owner / name\n    return spaces_dir / space_id.replace(\"/\", \"_\")\n\n\ndef _get_metadata_path(space_dir: Path) -> Path:\n    return space_dir / \".daggr_metadata.json\"\n\n\ndef _hash_file(file_path: Path) -> str:\n    if not file_path.exists():\n        return \"\"\n    return hashlib.sha256(file_path.read_bytes()).hexdigest()[:16]\n\n\ndef _find_free_port(start: int = 7861, end: int = 7960) -> int:\n    for port in range(start, end):\n        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n            try:\n                s.bind((\"127.0.0.1\", port))\n                return port\n            except OSError:\n                continue\n    raise RuntimeError(f\"No free ports available in range {start}-{end}\")\n\n\ndef _is_space_id(src: str) -> bool:\n    if src.startswith(\"http://\") or src.startswith(\"https://\"):\n        return False\n    return \"/\" in src and not src.startswith(\"/\")\n\n\nclass LocalSpaceManager:\n    def __init__(self, node: GradioNode):\n        self.node = node\n        self.space_id = node._src\n        self.space_dir = _get_space_dir(self.space_id)\n        self.repo_dir = self.space_dir / \"repo\"\n        self.venv_dir = self.space_dir / \".venv\"\n        self.metadata_path = _get_metadata_path(self.space_dir)\n        self.process: subprocess.Popen | None = None\n        self.local_url: str | None = None\n\n    def ensure_ready(self) -> str:\n        if not _is_space_id(self.space_id):\n            raise ValueError(\n                f\"Cannot run locally: '{self.space_id}' is not a valid Space ID. \"\n                \"Local mode only works with Hugging Face Spaces (format: 'owner/space-name').\"\n            )\n\n        try:\n            self._ensure_cloned()\n            self._ensure_venv()\n            url = self._launch_app()\n            return url\n        except Exception as e:\n            self._log_error(e)\n            raise\n\n    def _ensure_cloned(self) -> None:\n        metadata = self._load_metadata()\n\n        if self.repo_dir.exists() and metadata:\n            should_update = os.environ.get(\"DAGGR_UPDATE_SPACES\") == \"1\"\n            if not should_update:\n                return\n\n        self.space_dir.mkdir(parents=True, exist_ok=True)\n\n        from huggingface_hub import snapshot_download\n\n        print(f\"  Cloning Space '{self.space_id}'...\")\n\n        if self.repo_dir.exists():\n            shutil.rmtree(self.repo_dir)\n\n        snapshot_download(\n            repo_id=self.space_id,\n            repo_type=\"space\",\n            local_dir=self.repo_dir,\n        )\n\n        requirements_path = self.repo_dir / \"requirements.txt\"\n        metadata = {\n            \"cloned_at\": datetime.now().isoformat(),\n            \"space_id\": self.space_id,\n            \"requirements_hash\": _hash_file(requirements_path),\n            \"python_version\": f\"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\",\n        }\n        self._save_metadata(metadata)\n        print(f\"  Cloned to {self.repo_dir}\")\n\n    def _get_sdk_version(self) -> str | None:\n        readme_path = self.repo_dir / \"README.md\"\n        if not readme_path.exists():\n            return None\n\n        try:\n            content = readme_path.read_text()\n            if not content.startswith(\"---\"):\n                return None\n\n            parts = content.split(\"---\", 2)\n            if len(parts) < 3:\n                return None\n\n            match = re.search(r\"sdk_version:\\s*['\\\"]?([^\\s'\\\"]+)\", parts[1])\n            if match:\n                return match.group(1)\n        except Exception:\n            pass\n\n        return None\n\n    def _ensure_venv(self) -> None:\n        requirements_path = self.repo_dir / \"requirements.txt\"\n        current_hash = _hash_file(requirements_path)\n        metadata = self._load_metadata()\n\n        venv_python = self.venv_dir / \"bin\" / \"python\"\n        if sys.platform == \"win32\":\n            venv_python = self.venv_dir / \"Scripts\" / \"python.exe\"\n\n        needs_reinstall = False\n        if not self.venv_dir.exists() or not venv_python.exists():\n            needs_reinstall = True\n        elif metadata and metadata.get(\"requirements_hash\") != current_hash:\n            needs_reinstall = True\n\n        if not needs_reinstall:\n            return\n\n        print(f\"  Setting up virtual environment for '{self.space_id}'...\")\n\n        if self.venv_dir.exists():\n            shutil.rmtree(self.venv_dir)\n\n        subprocess.run(\n            [sys.executable, \"-m\", \"venv\", str(self.venv_dir)],\n            check=True,\n            capture_output=True,\n        )\n\n        pip_path = self.venv_dir / \"bin\" / \"pip\"\n        if sys.platform == \"win32\":\n            pip_path = self.venv_dir / \"Scripts\" / \"pip.exe\"\n\n        subprocess.run(\n            [str(pip_path), \"install\", \"--upgrade\", \"pip\"],\n            check=True,\n            capture_output=True,\n        )\n\n        sdk_version = self._get_sdk_version()\n        if sdk_version:\n            gradio_pkg = f\"gradio=={sdk_version}\"\n            print(f\"  Installing {gradio_pkg}...\")\n        else:\n            gradio_pkg = \"gradio\"\n            print(\"  Installing gradio (latest)...\")\n\n        result = subprocess.run(\n            [str(pip_path), \"install\", gradio_pkg],\n            capture_output=True,\n            text=True,\n        )\n        if result.returncode != 0:\n            error_msg = result.stderr or result.stdout\n            self._log_to_file(\"pip_install_gradio\", error_msg)\n            print(f\"    Warning: Failed to install {gradio_pkg}\")\n\n        if requirements_path.exists():\n            print(f\"  Installing dependencies from {requirements_path}...\")\n            print(\"  (this may take a few minutes)\")\n\n            process = subprocess.Popen(\n                [str(pip_path), \"install\", \"-r\", str(requirements_path)],\n                stdout=subprocess.PIPE,\n                stderr=subprocess.STDOUT,\n                text=True,\n                bufsize=1,\n            )\n\n            output_lines = []\n            for line in iter(process.stdout.readline, \"\"):\n                output_lines.append(line)\n                line_stripped = line.strip()\n                if line_stripped.startswith(\"Collecting \"):\n                    pkg = line_stripped.replace(\"Collecting \", \"\").split()[0]\n                    print(f\"    Installing {pkg}...\")\n                elif (\n                    line_stripped.startswith(\"ERROR:\")\n                    or \"error\" in line_stripped.lower()\n                ):\n                    print(f\"    {line_stripped}\")\n\n            process.wait()\n\n            if process.returncode != 0:\n                error_msg = \"\".join(output_lines)\n                self._log_to_file(\"pip_install\", error_msg)\n                print(\"\\n  ❌ Dependency installation failed!\")\n                print(f\"  Full log: {self._get_log_path('pip_install')}\")\n                raise RuntimeError(\n                    f\"Failed to install dependencies for '{self.space_id}'.\\n\"\n                    f\"See logs at: {self._get_log_path('pip_install')}\\n\"\n                    f\"You can try installing manually:\\n\"\n                    f\"  {pip_path} install -r {requirements_path}\"\n                )\n\n        if metadata:\n            metadata[\"requirements_hash\"] = current_hash\n            self._save_metadata(metadata)\n\n        print(\"  Virtual environment ready\")\n\n    def _launch_app(self) -> str:\n        global _running_processes\n\n        if self.space_id in _running_processes:\n            proc = _running_processes[self.space_id]\n            if proc.poll() is None:\n                metadata = self._load_metadata()\n                if metadata and metadata.get(\"local_url\"):\n                    return metadata[\"local_url\"]\n\n        app_file = self._find_app_file()\n        if not app_file:\n            raise RuntimeError(\n                f\"No app.py or main.py found in '{self.space_id}'. \"\n                \"Cannot determine how to launch this Space.\"\n            )\n\n        port = _find_free_port()\n        local_url = f\"http://127.0.0.1:{port}\"\n\n        venv_python = self.venv_dir / \"bin\" / \"python\"\n        if sys.platform == \"win32\":\n            venv_python = self.venv_dir / \"Scripts\" / \"python.exe\"\n\n        timeout = int(os.environ.get(\"DAGGR_LOCAL_TIMEOUT\", \"120\"))\n\n        env = os.environ.copy()\n        env[\"GRADIO_SERVER_PORT\"] = str(port)\n        env[\"GRADIO_SERVER_NAME\"] = \"127.0.0.1\"\n        env[\"PYTHONUNBUFFERED\"] = \"1\"\n\n        print(f\"  Launching '{self.space_id}' on port {port}...\")\n        print(f\"  Waiting for app to start (timeout: {timeout}s)...\")\n\n        log_file = self._get_log_path(\"launch\")\n        log_file.parent.mkdir(parents=True, exist_ok=True)\n\n        self.process = subprocess.Popen(\n            [str(venv_python), str(app_file)],\n            cwd=str(self.repo_dir),\n            env=env,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.STDOUT,\n            text=True,\n        )\n\n        _running_processes[self.space_id] = self.process\n\n        ready, error_output = self._wait_for_ready(local_url, timeout, verbose=True)\n        if not ready:\n            self._log_to_file(\"launch\", error_output)\n            if self.process.poll() is None:\n                self.process.terminate()\n\n            print(\"\\n  ❌ Space failed to start!\")\n            if error_output:\n                error_lines = error_output.strip().split(\"\\n\")\n                relevant_lines = [ln for ln in error_lines if ln.strip()][-10:]\n                if relevant_lines:\n                    print(\"  Last output:\")\n                    for line in relevant_lines:\n                        print(f\"    {line}\")\n\n            print(f\"  Full log: {log_file}\")\n            raise RuntimeError(\n                f\"Space '{self.space_id}' failed to start.\\n\"\n                f\"See logs at: {log_file}\\n\"\n                \"Suggestions:\\n\"\n                \"  1. Some Spaces require GPU hardware\\n\"\n                \"  2. Check the Space's README for requirements\\n\"\n                \"  3. Set DAGGR_LOCAL_VERBOSE=1 to see all output\"\n            )\n\n        metadata = self._load_metadata() or {}\n        metadata[\"local_url\"] = local_url\n        metadata[\"last_successful_launch\"] = datetime.now().isoformat()\n        self._save_metadata(metadata)\n\n        print(f\"  Space running at {local_url}\")\n        return local_url\n\n    def _find_app_file(self) -> Path | None:\n        for name in [\"app.py\", \"main.py\", \"demo.py\"]:\n            path = self.repo_dir / name\n            if path.exists():\n                return path\n        return None\n\n    def _wait_for_ready(\n        self, url: str, timeout: int, verbose: bool = False\n    ) -> tuple[bool, str]:\n        output_lines: list[str] = []\n        start = time.time()\n        last_status_time = start\n        saw_error = False\n\n        while time.time() - start < timeout:\n            if self.process and self.process.stdout:\n                while True:\n                    if sys.platform == \"win32\":\n                        line = self.process.stdout.readline()\n                        if not line:\n                            break\n                    else:\n                        ready, _, _ = select.select([self.process.stdout], [], [], 0)\n                        if not ready:\n                            break\n                        line = self.process.stdout.readline()\n\n                    if line:\n                        output_lines.append(line)\n                        line_lower = line.lower()\n                        if (\n                            \"traceback\" in line_lower\n                            or \"modulenotfounderror\" in line_lower\n                        ):\n                            saw_error = True\n                        if verbose:\n                            print(f\"    [app] {line.rstrip()}\")\n\n            exit_code = self.process.poll() if self.process else None\n            if exit_code is not None:\n                if self.process and self.process.stdout:\n                    remaining = self.process.stdout.read()\n                    if remaining:\n                        output_lines.append(remaining)\n                        if verbose:\n                            for rem_line in remaining.strip().split(\"\\n\"):\n                                if rem_line.strip():\n                                    print(f\"    [app] {rem_line}\")\n                print(f\"  App process exited with code {exit_code}\")\n                return False, \"\".join(output_lines)\n\n            if saw_error:\n                time.sleep(0.5)\n                if self.process and self.process.poll() is not None:\n                    if self.process.stdout:\n                        remaining = self.process.stdout.read()\n                        if remaining:\n                            output_lines.append(remaining)\n                    print(\"  App crashed during startup\")\n                    return False, \"\".join(output_lines)\n\n            elapsed = time.time() - start\n            if elapsed - (last_status_time - start) >= 10:\n                print(f\"    Still waiting... ({int(elapsed)}s elapsed)\")\n                last_status_time = time.time()\n\n            try:\n                with urllib.request.urlopen(url, timeout=2) as response:\n                    if response.status == 200:\n                        return True, \"\".join(output_lines)\n            except (urllib.error.URLError, OSError):\n                pass\n\n            time.sleep(0.3)\n\n        return False, \"\".join(output_lines)\n\n    def _load_metadata(self) -> dict[str, Any] | None:\n        if not self.metadata_path.exists():\n            return None\n        try:\n            return json.loads(self.metadata_path.read_text())\n        except (json.JSONDecodeError, OSError):\n            return None\n\n    def _save_metadata(self, metadata: dict[str, Any]) -> None:\n        self.metadata_path.parent.mkdir(parents=True, exist_ok=True)\n        self.metadata_path.write_text(json.dumps(metadata, indent=2))\n\n    def _get_log_path(self, log_type: str) -> Path:\n        logs_dir = _get_logs_dir()\n        logs_dir.mkdir(parents=True, exist_ok=True)\n        safe_name = self.space_id.replace(\"/\", \"_\")\n        timestamp = datetime.now().strftime(\"%Y-%m-%d\")\n        return logs_dir / f\"{safe_name}_{log_type}_{timestamp}.log\"\n\n    def _log_to_file(self, log_type: str, content: str) -> None:\n        log_path = self._get_log_path(log_type)\n        log_path.parent.mkdir(parents=True, exist_ok=True)\n        with open(log_path, \"w\") as f:\n            f.write(f\"Timestamp: {datetime.now().isoformat()}\\n\")\n            f.write(f\"Space: {self.space_id}\\n\")\n            f.write(f\"Type: {log_type}\\n\")\n            f.write(\"=\" * 50 + \"\\n\")\n            f.write(content)\n\n    def _log_error(self, error: Exception) -> None:\n        self._log_to_file(\"error\", str(error))\n\n\ndef prepare_local_node(node: GradioNode) -> None:\n    if node._local_failed or node._local_url:\n        return\n\n    if not _is_space_id(node._src):\n        return\n\n    no_fallback = os.environ.get(\"DAGGR_LOCAL_NO_FALLBACK\") == \"1\"\n\n    try:\n        manager = LocalSpaceManager(node)\n        url = manager.ensure_ready()\n        node._local_url = url\n    except Exception as e:\n        node._local_failed = True\n        safe_name = node._src.replace(\"/\", \"_\")\n\n        print(f\"\\n  ⚠️  Local setup failed for '{node._src}'\")\n        print(f\"  Reason: {e}\")\n        print(f\"  Logs: {_get_logs_dir()}/{safe_name}_*.log\")\n\n        if no_fallback:\n            raise RuntimeError(\n                f\"Local execution failed for '{node._src}' and fallback is disabled. \"\n                f\"Error: {e}\"\n            ) from e\n\n        print(\"  Will fall back to remote API at execution time.\\n\")\n\n\ndef get_local_client(node: GradioNode) -> Any:\n    if node._local_failed:\n        return None\n\n    if node._local_url:\n        from gradio_client import Client\n\n        return Client(node._local_url, download_files=False, verbose=False)\n\n    return None\n\n\ndef cleanup_local_processes() -> None:\n    global _running_processes\n    for space_id, proc in list(_running_processes.items()):\n        if proc.poll() is None:\n            proc.terminate()\n            try:\n                proc.wait(timeout=5)\n            except subprocess.TimeoutExpired:\n                proc.kill()\n    _running_processes.clear()\n\n\natexit.register(cleanup_local_processes)\n"
  },
  {
    "path": "daggr/node.py",
    "content": "\"\"\"Node types for daggr graphs.\n\nThis module defines the various node types that can be used in a daggr graph:\n- Node: Abstract base class for all nodes\n- GradioNode: Wraps a Gradio Space or endpoint\n- InferenceNode: Wraps a Hugging Face Inference API model\n- FnNode: Wraps a Python function\n- InteractionNode: Represents user interaction points\n\"\"\"\n\nfrom __future__ import annotations\n\nimport inspect\nimport warnings\nfrom abc import ABC\nfrom collections.abc import Callable\nfrom typing import Any\n\nfrom daggr._utils import suggest_similar\nfrom daggr.port import ItemList, Port, PortNamespace, is_port\n\n_FILE_TYPE_COMPONENTS = {\n    \"Image\",\n    \"Audio\",\n    \"Video\",\n    \"File\",\n    \"Gallery\",\n    \"ImageEditor\",\n    \"ImageSlider\",\n}\n\n\ndef _warn_if_type_set(component: Any, port_name: str) -> None:\n    constructor_args = getattr(component, \"_constructor_args\", None)\n    if not constructor_args:\n        return\n    comp_type = constructor_args[0].get(\"type\")\n    if comp_type is None:\n        return\n    class_name = type(component).__name__\n    if class_name not in _FILE_TYPE_COMPONENTS:\n        return\n    if comp_type != \"filepath\":\n        warnings.warn(\n            f\"Gradio component {class_name}(type={comp_type!r}) on port '{port_name}': \"\n            f\"daggr ignores the `type` parameter. All file data is passed as file path \"\n            f\"strings regardless of this setting.\",\n            stacklevel=4,\n        )\n\n\ndef _is_gradio_component(obj: Any) -> bool:\n    if obj is None:\n        return False\n    class_name = obj.__class__.__name__\n    module = getattr(obj.__class__, \"__module__\", \"\")\n    return \"gradio\" in module or class_name in (\n        \"Textbox\",\n        \"TextArea\",\n        \"Audio\",\n        \"Image\",\n        \"JSON\",\n        \"Markdown\",\n        \"Number\",\n        \"Checkbox\",\n        \"Dropdown\",\n        \"Radio\",\n        \"Slider\",\n        \"File\",\n        \"Video\",\n        \"Gallery\",\n        \"Chatbot\",\n        \"Text\",\n    )\n\n\nclass Node(ABC):\n    \"\"\"Abstract base class for all nodes in a daggr graph.\n\n    Nodes represent processing steps in a DAG. Each node has named input and\n    output ports that can be connected to form a data processing pipeline.\n\n    Ports can be accessed as attributes: `node.port_name` returns a Port object.\n\n    Args:\n        name: Optional display name for the node. If not provided, a name will\n            be auto-generated based on the node type.\n    \"\"\"\n\n    _id_counter = 0\n\n    def __init__(self, name: str | None = None):\n        self._id = Node._id_counter\n        Node._id_counter += 1\n        self._name = name or \"\"\n        self._name_explicitly_set = bool(name)\n        self._input_ports: list[str] = []\n        self._output_ports: list[str] = []\n        self._input_components: dict[str, Any] = {}\n        self._output_components: dict[str, Any] = {}\n        self._item_list_schemas: dict[str, dict[str, Any]] = {}\n        self._fixed_inputs: dict[str, Any] = {}\n        self._port_connections: dict[str, Any] = {}\n\n    @property\n    def name(self) -> str:\n        return self._name\n\n    @name.setter\n    def name(self, value: str) -> None:\n        self._name = value\n        self._name_explicitly_set = True\n\n    def __getattr__(self, name: str) -> Port:\n        if name.startswith(\"_\"):\n            raise AttributeError(name)\n        return Port(self, name)\n\n    def __dir__(self) -> list[str]:\n        base = [\"_name\", \"_inputs\", \"_outputs\", \"_input_ports\", \"_output_ports\"]\n        return base + self._input_ports + self._output_ports\n\n    def __or__(self, other: Node) -> ChoiceNode:\n        \"\"\"Combine two nodes as alternatives using the | operator.\n\n        Returns a ChoiceNode that lets users pick which variant to run.\n\n        Example:\n            >>> tts = GradioNode(\"space1/tts\", ...) | GradioNode(\"space2/tts\", ...)\n            >>> # tts.audio works regardless of which variant is selected\n        \"\"\"\n        if isinstance(other, ChoiceNode):\n            return ChoiceNode([self] + other._variants, name=self._name)\n        return ChoiceNode([self, other], name=self._name)\n\n    @property\n    def _inputs(self) -> PortNamespace:\n        return PortNamespace(self, self._input_ports)\n\n    @property\n    def _outputs(self) -> PortNamespace:\n        return PortNamespace(self, self._output_ports)\n\n    def _default_output_port(self) -> Port:\n        if self._output_ports:\n            return Port(self, self._output_ports[0])\n        return Port(self, \"output\")\n\n    def _default_input_port(self) -> Port:\n        if self._input_ports:\n            return Port(self, self._input_ports[0])\n        return Port(self, \"input\")\n\n    def _validate_ports(self):\n        all_ports = set(self._input_ports + self._output_ports)\n        underscore_ports = [p for p in all_ports if p.startswith(\"_\")]\n        if underscore_ports:\n            warnings.warn(\n                f\"Port names {underscore_ports} start with underscore. \"\n                f\"Use node._inputs.{underscore_ports[0]} or node._outputs.{underscore_ports[0]} to access.\"\n            )\n\n    def _process_inputs(self, inputs: dict[str, Any]) -> None:\n        for port_name, value in inputs.items():\n            self._input_ports.append(port_name)\n            if is_port(value):\n                self._port_connections[port_name] = value\n            elif _is_gradio_component(value):\n                _warn_if_type_set(value, port_name)\n                self._input_components[port_name] = value\n            else:\n                self._fixed_inputs[port_name] = value\n\n    def _process_outputs(self, outputs: dict[str, Any]) -> None:\n        for port_name, component in outputs.items():\n            self._output_ports.append(port_name)\n            if component is not None and _is_gradio_component(component):\n                _warn_if_type_set(component, port_name)\n                self._output_components[port_name] = component\n\n    def test(self, **inputs) -> dict[str, Any]:\n        \"\"\"Test-run this node in isolation and return the raw result.\n\n        If no inputs are provided, auto-generates example values using:\n        - Gradio component's .example_value() method\n        - Port's associated output component's .example_value()\n        - Callable inputs are called\n        - Fixed values are used directly\n\n        Args:\n            **inputs: Override inputs for the test run.\n\n        Returns:\n            Dict mapping output port names to their values.\n\n        Example:\n            >>> tts = GradioNode(\"mrfakename/MeloTTS\", api_name=\"/synthesize\", ...)\n            >>> result = tts.test(text=\"Hello world\", speaker=\"EN-US\")\n            >>> # Returns: {\"audio\": \"/path/to/audio.wav\"}\n            >>>\n            >>> # Or with auto-generated example values:\n            >>> result = tts.test()\n        \"\"\"\n        from daggr import Graph\n        from daggr.executor import SequentialExecutor\n\n        if not inputs:\n            inputs = self._generate_example_inputs()\n\n        graph = Graph(\"_test\", nodes=[self], persist_key=False)\n        executor = SequentialExecutor(graph)\n        return executor.execute_node(self._name, inputs)\n\n    def _generate_example_inputs(self) -> dict[str, Any]:\n        \"\"\"Generate example values for all input ports.\"\"\"\n        examples = {}\n\n        # From input components (Gradio components)\n        for port_name, comp in self._input_components.items():\n            if hasattr(comp, \"example_value\"):\n                examples[port_name] = comp.example_value()\n\n        # From fixed inputs (constants, callables, or port connections)\n        for port_name, source in self._fixed_inputs.items():\n            if callable(source):\n                examples[port_name] = source()\n            else:\n                examples[port_name] = source\n\n        # From port connections (use the connected port's output component)\n        for port_name, port in self._port_connections.items():\n            if is_port(port):\n                comp = port._node._output_components.get(port._port_name)\n                if comp and hasattr(comp, \"example_value\"):\n                    examples[port_name] = comp.example_value()\n\n        return examples\n\n    def __repr__(self):\n        return f\"{self.__class__.__name__}(name={self._name})\"\n\n\nclass ChoiceNode(Node):\n    \"\"\"A node that wraps multiple alternative nodes.\n\n    ChoiceNode allows users to select which variant to run from a set of\n    alternatives. Created using the | operator between nodes.\n\n    The output ports are the union of all variants' output ports, so downstream\n    nodes can connect to any output that exists in at least one variant.\n\n    Args:\n        variants: List of Node objects that serve as alternatives.\n        name: Optional display name. Defaults to the first variant's name.\n\n    Example:\n        >>> tts = GradioNode(\"space1/tts\", ...) | GradioNode(\"space2/tts\", ...)\n        >>> # tts is a ChoiceNode with two variants\n        >>> # tts.audio works regardless of which variant is selected\n    \"\"\"\n\n    def __init__(\n        self,\n        variants: list[Node],\n        name: str | None = None,\n    ):\n        if not variants:\n            raise ValueError(\"ChoiceNode requires at least one variant\")\n\n        super().__init__(name)\n        self._variants = variants\n        self._selected_variant = 0\n\n        if not self._name:\n            self._name = variants[0]._name\n\n        self._output_ports = self._compute_union_output_ports()\n        self._output_components = self._compute_union_output_components()\n\n        for variant in variants:\n            for port_name, port in variant._port_connections.items():\n                if port_name not in self._port_connections:\n                    self._port_connections[port_name] = port\n\n    def _compute_union_output_ports(self) -> list[str]:\n        seen = set()\n        ports = []\n        for variant in self._variants:\n            for port in variant._output_ports:\n                if port not in seen:\n                    seen.add(port)\n                    ports.append(port)\n        return ports\n\n    def _compute_union_output_components(self) -> dict[str, Any]:\n        components = {}\n        for variant in self._variants:\n            for port_name, comp in variant._output_components.items():\n                if port_name not in components:\n                    components[port_name] = comp\n        return components\n\n    def __or__(self, other: Node) -> ChoiceNode:\n        if isinstance(other, ChoiceNode):\n            return ChoiceNode(self._variants + other._variants, name=self._name)\n        return ChoiceNode(self._variants + [other], name=self._name)\n\n    def __repr__(self):\n        variant_names = [v._name for v in self._variants]\n        return f\"ChoiceNode(name={self._name}, variants={variant_names})\"\n\n\nclass GradioNode(Node):\n    \"\"\"A node that wraps a Gradio Space or endpoint.\n\n    GradioNode connects to a Hugging Face Space or any Gradio app and exposes\n    its API as a node in the graph.\n\n    Args:\n        space_or_url: Hugging Face Space ID (e.g., \"username/space-name\") or\n            a full URL to a Gradio app.\n        api_name: The API endpoint to call (e.g., \"/predict\"). Defaults to \"/predict\".\n        name: Optional display name for the node.\n        inputs: Dict mapping input port names to Gradio components, Port connections,\n            or fixed values.\n        outputs: Dict mapping output port names to Gradio components for display.\n        validate: Whether to validate the Space exists and has the specified endpoint.\n        run_locally: If True, clone and run the Space locally instead of using the\n            remote API.\n\n    Example:\n        >>> tts = GradioNode(\n        ...     \"mrfakename/MeloTTS\",\n        ...     api_name=\"/synthesize\",\n        ...     inputs={\"text\": gr.Textbox(), \"speaker\": \"EN-US\"},\n        ...     outputs={\"audio\": gr.Audio()},\n        ... )\n    \"\"\"\n\n    _name_counters: dict[str, int] = {}\n\n    def __init__(\n        self,\n        space_or_url: str,\n        api_name: str | None = None,\n        name: str | None = None,\n        inputs: dict[str, Any] | None = None,\n        outputs: dict[str, Any] | None = None,\n        validate: bool = True,\n        run_locally: bool = False,\n        preprocess: Callable[[dict], dict] | None = None,\n        postprocess: Callable[..., Any] | None = None,\n    ):\n        super().__init__(name)\n        self._src = space_or_url\n        self._api_name = api_name\n        self._run_locally = run_locally\n        self._local_url: str | None = None\n        self._local_failed = False\n        self._preprocess = preprocess\n        self._postprocess = postprocess\n\n        if validate:\n            self._validate_space_format()\n\n        if not self._name:\n            base_name = self._src.split(\"/\")[-1]\n            if base_name not in GradioNode._name_counters:\n                GradioNode._name_counters[base_name] = 0\n                self._name = base_name\n            else:\n                GradioNode._name_counters[base_name] += 1\n                self._name = f\"{base_name}_{GradioNode._name_counters[base_name]}\"\n\n        self._process_inputs(inputs or {})\n        self._process_outputs(outputs or {})\n        self._validate_ports()\n\n        if validate and not run_locally:\n            self._validate_gradio_api(inputs or {}, outputs or {})\n\n    def _validate_space_format(self) -> None:\n        src = self._src\n        if not (\"/\" in src or src.startswith(\"http://\") or src.startswith(\"https://\")):\n            raise ValueError(\n                f\"Invalid space_or_url '{src}'. Expected format: 'username/space-name' \"\n                f\"or a full URL like 'https://...'\"\n            )\n\n    def _get_api_info(self) -> dict:\n        from daggr import _client_cache\n\n        cached = _client_cache.get_api_info(self._src)\n        if cached is not None:\n            return cached\n\n        from gradio_client import Client\n\n        client = _client_cache.get_client(self._src)\n        if client is None:\n            client = Client(self._src, download_files=False, verbose=False)\n            _client_cache.set_client(self._src, client)\n\n        api_info = client.view_api(return_format=\"dict\", print_info=False)\n        _client_cache.set_api_info(self._src, api_info)\n        return api_info\n\n    def _validate_gradio_api(\n        self, inputs: dict[str, Any], outputs: dict[str, Any]\n    ) -> None:\n        from daggr import _client_cache\n\n        api_name = self._api_name or \"/predict\"\n        if not api_name.startswith(\"/\"):\n            api_name = \"/\" + api_name\n\n        cache_key = (\n            self._src,\n            api_name,\n            tuple(sorted(inputs.keys())),\n            tuple(sorted(outputs.keys())) if outputs else (),\n        )\n        if _client_cache.is_validated(cache_key):\n            return\n\n        api_info = self._get_api_info()\n\n        named_endpoints = api_info.get(\"named_endpoints\", {})\n        unnamed_endpoints = api_info.get(\"unnamed_endpoints\", {})\n\n        endpoint_info = None\n        if api_name in named_endpoints:\n            endpoint_info = named_endpoints[api_name]\n        else:\n            try:\n                fn_index = int(api_name.lstrip(\"/\"))\n                if fn_index in unnamed_endpoints or str(fn_index) in unnamed_endpoints:\n                    endpoint_info = unnamed_endpoints.get(\n                        fn_index, unnamed_endpoints.get(str(fn_index))\n                    )\n            except ValueError:\n                pass\n\n        if endpoint_info is None:\n            available = list(named_endpoints.keys())\n            if unnamed_endpoints:\n                available.extend([f\"/{k}\" for k in unnamed_endpoints.keys()])\n            suggested = suggest_similar(api_name, set(available))\n            msg = (\n                f\"API endpoint '{api_name}' not found in '{self._src}'. \"\n                f\"Available endpoints: {available}\"\n            )\n            if suggested:\n                msg += f\" Did you mean '{suggested}'?\"\n            raise ValueError(msg)\n\n        params_info = endpoint_info.get(\"parameters\", [])\n        valid_params = {p.get(\"parameter_name\", p[\"label\"]) for p in params_info}\n        input_params = set(inputs.keys())\n        invalid_params = input_params - valid_params\n\n        if invalid_params:\n            suggestions = {}\n            for inv in invalid_params:\n                suggestion = suggest_similar(inv, valid_params)\n                if suggestion:\n                    suggestions[inv] = suggestion\n            msg = (\n                f\"Invalid parameter(s) {invalid_params} for endpoint '{api_name}' \"\n                f\"in '{self._src}'.\"\n            )\n            if suggestions:\n                suggestion_str = \", \".join(\n                    f\"'{k}' -> '{v}'\" for k, v in suggestions.items()\n                )\n                msg += f\" Did you mean: {suggestion_str}?\"\n            msg += f\" Valid parameters: {valid_params}\"\n            raise ValueError(msg)\n\n        required_params = {\n            p.get(\"parameter_name\", p[\"label\"])\n            for p in params_info\n            if not p.get(\"parameter_has_default\", False)\n        }\n        provided_params = set(inputs.keys())\n        missing_required = required_params - provided_params\n\n        if missing_required:\n            raise ValueError(\n                f\"Missing required parameter(s) {missing_required} for endpoint \"\n                f\"'{api_name}' in '{self._src}'. These parameters have no default values.\"\n            )\n\n        api_returns = endpoint_info.get(\"returns\", [])\n        if outputs and api_returns and not self._postprocess:\n            num_returns = len(api_returns)\n            num_outputs = len(outputs)\n            if num_outputs > num_returns:\n                warnings.warn(\n                    f\"GradioNode '{self._name}' defines {num_outputs} outputs but \"\n                    f\"endpoint '{api_name}' only returns {num_returns} value(s). \"\n                    f\"Extra outputs will be None.\"\n                )\n\n        _client_cache.mark_validated(cache_key)\n\n\nclass InferenceNode(Node):\n    \"\"\"A node that wraps a Hugging Face Inference API model.\n\n    InferenceNode uses the Hugging Face Inference API to run models without\n    needing to download them locally. The task type (text-generation, text-to-image,\n    etc.) is automatically determined from the model's pipeline_tag on the Hub.\n\n    Args:\n        model: The Hugging Face model ID (e.g., \"meta-llama/Llama-2-7b-chat-hf\").\n        name: Optional display name for the node.\n        inputs: Dict mapping input port names to values or components.\n        outputs: Dict mapping output port names to components.\n        validate: Whether to validate the model exists on the Hub.\n        preprocess: Optional function that receives the input dict and returns a\n            modified dict before the inference call.\n        postprocess: Optional function that receives the raw inference result and\n            returns a transformed value before it is mapped to output ports.\n\n    Example:\n        >>> llm = InferenceNode(\"meta-llama/Llama-2-7b-chat-hf\")\n    \"\"\"\n\n    def __init__(\n        self,\n        model: str,\n        name: str | None = None,\n        inputs: dict[str, Any] | None = None,\n        outputs: dict[str, Any] | None = None,\n        validate: bool = True,\n        preprocess: Callable[[dict], dict] | None = None,\n        postprocess: Callable[..., Any] | None = None,\n    ):\n        super().__init__(name)\n        self._model = model\n        self._task: str | None = None\n        self._task_fetched: bool = False\n        self._preprocess = preprocess\n        self._postprocess = postprocess\n\n        if not self._name:\n            # Strip provider tag (e.g., \":replicate\") for display name\n            self._name = self._model_name_for_hub.split(\"/\")[-1]\n\n        if inputs:\n            self._process_inputs(inputs)\n        else:\n            self._input_ports = [\"input\"]\n\n        if outputs:\n            self._process_outputs(outputs)\n        else:\n            self._output_ports = [\"output\"]\n\n        self._validate_ports()\n\n        if validate:\n            self._fetch_model_info()\n\n    @property\n    def _model_name_for_hub(self) -> str:\n        \"\"\"Return the model name without provider tags (e.g., ':replicate').\"\"\"\n        # HF Inference Client allows tags like \"model:provider\" for routing\n        # Strip these for Hub API calls and display\n        return self._model.split(\":\")[0]\n\n    @property\n    def _provider(self) -> str | None:\n        \"\"\"Return the provider tag if specified (e.g., 'replicate' from 'model:replicate').\"\"\"\n        parts = self._model.split(\":\")\n        return parts[1] if len(parts) > 1 else None\n\n    def _fetch_model_info(self) -> None:\n        if self._task_fetched:\n            return\n\n        from daggr import _client_cache\n\n        # Use model name without provider tag for Hub lookups\n        hub_model = self._model_name_for_hub\n\n        found_in_cache, cached = _client_cache.get_model_task(hub_model)\n        if found_in_cache:\n            if cached == \"__NOT_FOUND__\":\n                raise ValueError(f\"Model '{hub_model}' not found on Hugging Face Hub.\")\n            self._task = cached\n            self._task_fetched = True\n            return\n\n        from huggingface_hub import model_info\n        from huggingface_hub.utils import RepositoryNotFoundError\n\n        try:\n            info = model_info(hub_model)\n            self._task = info.pipeline_tag\n            _client_cache.set_model_task(hub_model, self._task)\n            self._task_fetched = True\n        except RepositoryNotFoundError:\n            _client_cache.set_model_not_found(hub_model)\n            raise ValueError(\n                f\"Model '{hub_model}' not found on Hugging Face Hub. \"\n                f\"Please check the model name is correct (format: 'username/model-name').\"\n            )\n\n\nclass FnNode(Node):\n    \"\"\"A node that wraps a Python function.\n\n    FnNode allows you to use any Python function as a node in the graph.\n    Input ports are automatically discovered from the function signature.\n\n    Return values are mapped to output ports in order, just like GradioNode:\n    - Single value: maps to the first output port\n    - Tuple: each element maps to the corresponding output port in order\n\n    Concurrency:\n        By default, FnNodes execute sequentially (one at a time per session)\n        to prevent resource contention. Use the concurrency parameters to\n        allow parallel execution:\n\n        - concurrent=True: Allow this node to run in parallel with others\n        - concurrency_group: Group nodes that share a resource (e.g., GPU)\n        - max_concurrent: Max parallel executions within a group (default: 1)\n\n        Note: GradioNode and InferenceNode always run concurrently since they\n        are external API calls. Prefer these over FnNode when possible.\n\n    Args:\n        fn: The Python function to wrap.\n        name: Optional display name. Defaults to the function name.\n        inputs: Optional dict to explicitly define input ports and their\n            connections or UI components.\n        outputs: Optional dict mapping output port names to UI components\n            or ItemList schemas.\n        concurrent: If True, allow parallel execution. Default: False.\n        concurrency_group: Name of a group sharing a concurrency limit.\n        max_concurrent: Max parallel executions in the group. Default: 1.\n\n    Example:\n        >>> def process_text(text: str) -> tuple[str, int]:\n        ...     return text.upper(), len(text)\n        >>> node = FnNode(\n        ...     process_text,\n        ...     outputs={\"uppercase\": gr.Textbox(), \"length\": gr.Number()}\n        ... )\n\n        >>> # Allow parallel execution\n        >>> node = FnNode(my_func, concurrent=True)\n\n        >>> # Share GPU with other nodes (max 2 concurrent)\n        >>> node = FnNode(gpu_func, concurrency_group=\"gpu\", max_concurrent=2)\n    \"\"\"\n\n    def __init__(\n        self,\n        fn: Callable,\n        name: str | None = None,\n        inputs: dict[str, Any] | None = None,\n        outputs: dict[str, Any] | None = None,\n        preprocess: Callable[[dict], dict] | None = None,\n        postprocess: Callable[..., Any] | None = None,\n        concurrent: bool = False,\n        concurrency_group: str | None = None,\n        max_concurrent: int = 1,\n    ):\n        super().__init__(name)\n        self._fn = fn\n        self._preprocess = preprocess\n        self._postprocess = postprocess\n        self._concurrent = concurrent\n        self._concurrency_group = concurrency_group\n        self._max_concurrent = max_concurrent\n\n        if not self._name:\n            self._name = self._fn.__name__\n\n        if inputs:\n            self._validate_fn_inputs(inputs)\n            self._process_inputs(inputs)\n        else:\n            self._discover_signature()\n\n        if outputs:\n            self._process_outputs(outputs)\n        else:\n            self._output_ports = [\"output\"]\n\n        self._validate_ports()\n\n    def _discover_signature(self):\n        sig = inspect.signature(self._fn)\n        self._input_ports = list(sig.parameters.keys())\n\n    def _validate_fn_inputs(self, inputs: dict[str, Any]) -> None:\n        sig = inspect.signature(self._fn)\n        valid_params = set(sig.parameters.keys())\n        provided_params = set(inputs.keys())\n        invalid_params = provided_params - valid_params\n\n        if invalid_params:\n            suggestions = {}\n            for inv in invalid_params:\n                suggestion = suggest_similar(inv, valid_params)\n                if suggestion:\n                    suggestions[inv] = suggestion\n\n            msg = (\n                f\"Invalid input(s) {invalid_params} for function '{self._fn.__name__}'.\"\n            )\n            if suggestions:\n                suggestion_str = \", \".join(\n                    f\"'{k}' -> '{v}'\" for k, v in suggestions.items()\n                )\n                msg += f\" Did you mean: {suggestion_str}?\"\n            msg += f\" Valid parameters: {valid_params}\"\n            raise ValueError(msg)\n\n    def _process_outputs(self, outputs: dict[str, Any]) -> None:\n        for port_name, component in outputs.items():\n            self._output_ports.append(port_name)\n            if component is None:\n                continue\n            if isinstance(component, ItemList):\n                self._item_list_schemas[port_name] = component.schema\n            elif _is_gradio_component(component):\n                self._output_components[port_name] = component\n\n\nclass InteractionNode(Node):\n    \"\"\"A node representing a user interaction point in the graph.\n\n    InteractionNodes pause execution and wait for user input before continuing.\n    They are used for approval steps, selections, or other human-in-the-loop\n    interactions.\n\n    Args:\n        name: Optional display name for the node.\n        interaction_type: Type of interaction (e.g., \"generic\", \"approve\", \"choose_one\").\n        inputs: Dict mapping input port names to components or connections.\n        outputs: Dict mapping output port names to components.\n    \"\"\"\n\n    def __init__(\n        self,\n        name: str | None = None,\n        interaction_type: str = \"generic\",\n        inputs: dict[str, Any] | None = None,\n        outputs: dict[str, Any] | None = None,\n    ):\n        super().__init__(name)\n        self._interaction_type = interaction_type\n\n        if inputs:\n            self._process_inputs(inputs)\n        else:\n            self._input_ports = [\"input\"]\n\n        if outputs:\n            self._process_outputs(outputs)\n        else:\n            self._output_ports = [\"output\"]\n\n        if not self._name:\n            self._name = f\"interaction_{self._id}\"\n\n        self._validate_ports()\n\n\nclass InputNode(Node):\n    \"\"\"\n    A node that groups multiple Gradio input components into a single, organized block.\n    Each component defined in the `ports` dictionary becomes a distinct output port.\n    \"\"\"\n\n    _name_counters: dict[str, int] = {}\n\n    def __init__(\n        self,\n        name: str | None = None,\n        ports: dict[str, Any] | None = None,\n    ):\n        \"\"\"\n        Initializes the InputNode.\n\n        Args:\n            ports: A dictionary where keys are port names and values are Gradio components (e.g., gr.Textbox).\n            name: An optional display name for the node. A default name will be generated if not provided.\n        \"\"\"\n        super().__init__(name)\n\n        ports = ports or {}\n        if not isinstance(ports, dict):\n            raise TypeError(\n                \"InputNode `ports` must be a dictionary mapping port names to Gradio components.\"\n            )\n\n        invalid_ports = [\n            f\"{port_name} ({type(comp).__name__})\"\n            for port_name, comp in ports.items()\n            if not _is_gradio_component(comp)\n        ]\n        if invalid_ports:\n            invalid_ports_list = \", \".join(invalid_ports)\n            raise ValueError(\n                \"InputNode `ports` values must be Gradio components. \"\n                f\"Invalid entries: {invalid_ports_list}\"\n            )\n\n        self._output_ports = list(ports.keys())\n        self._input_components = dict(ports)\n\n        self._input_ports = []\n        self._output_components = {}\n\n        if not self._name:\n            base_name = \"Inputs\"\n            if base_name not in InputNode._name_counters:\n                InputNode._name_counters[base_name] = 0\n                self._name = base_name\n            else:\n                InputNode._name_counters[base_name] += 1\n                self._name = f\"{base_name}_{InputNode._name_counters[base_name]}\"\n\n        self._validate_ports()\n"
  },
  {
    "path": "daggr/ops.py",
    "content": "from __future__ import annotations\n\nfrom daggr.node import InteractionNode\n\n\nclass ChooseOne(InteractionNode):\n    _instance_counter = 0\n\n    def __init__(self, name: str | None = None):\n        ChooseOne._instance_counter += 1\n        super().__init__(\n            name=name or f\"choose_one_{ChooseOne._instance_counter}\",\n            interaction_type=\"choose_one\",\n        )\n        self._input_ports = [\"options\"]\n        self._output_ports = [\"selected\"]\n\n\nclass Approve(InteractionNode):\n    _instance_counter = 0\n\n    def __init__(self, name: str | None = None):\n        Approve._instance_counter += 1\n        super().__init__(\n            name=name or f\"approve_{Approve._instance_counter}\",\n            interaction_type=\"approve\",\n        )\n        self._input_ports = [\"input\"]\n        self._output_ports = [\"output\"]\n\n\nclass TextInput(InteractionNode):\n    _instance_counter = 0\n\n    def __init__(self, name: str | None = None, label: str = \"Input\"):\n        TextInput._instance_counter += 1\n        super().__init__(\n            name=name or f\"text_input_{TextInput._instance_counter}\",\n            interaction_type=\"text_input\",\n        )\n        self._label = label\n        self._input_ports = []\n        self._output_ports = [\"text\"]\n\n\nclass ImageInput(InteractionNode):\n    _instance_counter = 0\n\n    def __init__(self, name: str | None = None, label: str = \"Image\"):\n        ImageInput._instance_counter += 1\n        super().__init__(\n            name=name or f\"image_input_{ImageInput._instance_counter}\",\n            interaction_type=\"image_input\",\n        )\n        self._label = label\n        self._input_ports = []\n        self._output_ports = [\"image\"]\n"
  },
  {
    "path": "daggr/package.json",
    "content": "{\n\t\"name\": \"daggr\",\n\t\"version\": \"0.8.0\",\n\t\"description\": \"\",\n\t\"python\": \"true\"\n}\n"
  },
  {
    "path": "daggr/port.py",
    "content": "\"\"\"Port module for node input/output definitions.\n\nPorts are named connection points on nodes. Output ports can be connected\nto input ports to form edges in the graph.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any\n\nif TYPE_CHECKING:\n    from daggr.node import Node\n\n\nclass Port:\n    \"\"\"A named connection point on a node.\n\n    Ports represent inputs or outputs of a node. Access them as attributes\n    on a node: `node.port_name`.\n\n    Attributes:\n        node: The node this port belongs to.\n        name: The name of the port.\n    \"\"\"\n\n    def __init__(self, node: Node, name: str):\n        self.node = node\n        self.name = name\n\n    def __repr__(self):\n        return f\"Port({self.node._name}.{self.name})\"\n\n    def _as_source(self) -> tuple[Node, str]:\n        return (self.node, self.name)\n\n    def _as_target(self) -> tuple[Node, str]:\n        return (self.node, self.name)\n\n    def __getattr__(self, attr: str) -> ScatteredPort:\n        if attr.startswith(\"_\"):\n            raise AttributeError(attr)\n        if (\n            hasattr(self.node, \"_item_list_schemas\")\n            and self.name in self.node._item_list_schemas\n        ):\n            schema = self.node._item_list_schemas[self.name]\n            if attr in schema:\n                return ScatteredPort(self, attr)\n        raise AttributeError(f\"Port '{self.name}' has no attribute '{attr}'\")\n\n    @property\n    def each(self) -> ScatteredPort:\n        \"\"\"Scatter this port's output - run the downstream node once per item in the list.\"\"\"\n        return ScatteredPort(self)\n\n    def all(self) -> GatheredPort:\n        \"\"\"Gather outputs from a scattered node back into a list.\"\"\"\n        return GatheredPort(self)\n\n\nclass ScatteredPort:\n    \"\"\"A port that scatters its list output to run downstream nodes per-item.\n\n    Created by accessing `.each` on a port. When connected to a downstream\n    node, that node will be executed once for each item in the list.\n    \"\"\"\n\n    def __init__(self, port: Port, item_key: str | None = None):\n        self.port = port\n        self.item_key = item_key\n\n    @property\n    def node(self):\n        return self.port.node\n\n    @property\n    def name(self):\n        return self.port.name\n\n    def __getitem__(self, key: str) -> ScatteredPort:\n        \"\"\"Access a specific field from each scattered item (e.g., dialogue.json.each[\"text\"]).\"\"\"\n        return ScatteredPort(self.port, key)\n\n    def __repr__(self):\n        if self.item_key:\n            return f\"ScatteredPort({self.port}['{self.item_key}'])\"\n        return f\"ScatteredPort({self.port})\"\n\n\nclass GatheredPort:\n    \"\"\"A port that gathers scattered results back into a list.\n\n    Created by calling `.all()` on a port. Collects results from all\n    scattered executions back into a single list.\n    \"\"\"\n\n    def __init__(self, port: Port):\n        self.port = port\n\n    @property\n    def node(self):\n        return self.port.node\n\n    @property\n    def name(self):\n        return self.port.name\n\n    def __repr__(self):\n        return f\"GatheredPort({self.port})\"\n\n\nPortLike = Port | ScatteredPort | GatheredPort\n\n\ndef is_port(obj: Any) -> bool:\n    \"\"\"Check if an object is a Port, ScatteredPort, or GatheredPort.\"\"\"\n    return isinstance(obj, (Port, ScatteredPort, GatheredPort))\n\n\nclass PortNamespace:\n    \"\"\"A namespace for accessing ports that start with underscores.\n\n    Used via `node._inputs` or `node._outputs` to access ports whose names\n    start with underscores (which can't be accessed directly as attributes).\n    \"\"\"\n\n    def __init__(self, node: Node, port_names: list[str]):\n        self._node = node\n        self._names = set(port_names)\n\n    def __getattr__(self, name: str) -> Port:\n        if name.startswith(\"_\"):\n            raise AttributeError(name)\n        return Port(self._node, name)\n\n    def __dir__(self) -> list[str]:\n        return list(self._names)\n\n    def __repr__(self):\n        return f\"PortNamespace({list(self._names)})\"\n\n\nclass ItemList:\n    \"\"\"Define an editable list output with per-item schema.\n\n    Example:\n        outputs={\n            \"items\": ItemList(\n                speaker=gr.Dropdown(choices=[\"Host\", \"Guest\"]),\n                text=gr.Textbox(lines=2),\n            ),\n        }\n\n    The function should return a list of dicts matching the schema keys.\n    \"\"\"\n\n    def __init__(self, **schema):\n        self.schema = schema\n"
  },
  {
    "path": "daggr/py.typed",
    "content": ""
  },
  {
    "path": "daggr/server.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport base64\nimport json\nimport mimetypes\nimport os\nimport secrets\nimport socket\nimport tempfile\nimport threading\nimport time\nimport traceback\nimport uuid\nimport webbrowser\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nimport uvicorn\nfrom fastapi import FastAPI, Header, Request, WebSocket, WebSocketDisconnect\nfrom fastapi.responses import (\n    FileResponse,\n    HTMLResponse,\n    JSONResponse,\n    PlainTextResponse,\n    Response,\n)\nfrom gradio_client.utils import is_file_obj_with_meta\n\nfrom daggr.executor import AsyncExecutor, FileValue\nfrom daggr.node import (\n    _FILE_TYPE_COMPONENTS,\n    ChoiceNode,\n    GradioNode,\n    InferenceNode,\n    InputNode,\n    InteractionNode,\n)\nfrom daggr.session import ExecutionSession\nfrom daggr.state import SessionState, get_daggr_cache_dir\n\n_FILE_COMP_TYPES = {c.lower() for c in _FILE_TYPE_COMPONENTS}\n\nif TYPE_CHECKING:\n    from gradio.themes import Base as Theme\n\n    from daggr.graph import Graph\n\n\nINITIAL_PORT_VALUE = int(os.getenv(\"DAGGR_SERVER_PORT\", \"7860\"))\nTRY_NUM_PORTS = int(os.getenv(\"DAGGR_NUM_PORTS\", \"100\"))\n\n\ndef _find_available_port(host: str, start_port: int) -> int:\n    \"\"\"Find an available port starting from start_port.\"\"\"\n    for port in range(start_port, start_port + TRY_NUM_PORTS):\n        try:\n            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            s.bind((host if host != \"0.0.0.0\" else \"127.0.0.1\", port))\n            s.close()\n            return port\n        except OSError:\n            continue\n    raise OSError(\n        f\"Cannot find empty port in range: {start_port}-{start_port + TRY_NUM_PORTS - 1}. \"\n        f\"You can specify a different port by setting the DAGGR_SERVER_PORT environment variable \"\n        f\"or passing the port parameter to launch().\"\n    )\n\n\ndef _get_theme(theme: \"Theme | str | None\") -> \"Theme\":\n    \"\"\"Get a Gradio theme instance from a theme specification.\n\n    Args:\n        theme: Can be a Theme instance, a string name like \"default\", \"soft\",\n            \"monochrome\", \"glass\", or a Hub theme like \"gradio/seafoam\".\n\n    Returns:\n        A Theme instance.\n    \"\"\"\n    from gradio.themes import Default\n\n    if theme is None:\n        return Default()\n\n    if isinstance(theme, str):\n        from gradio.themes import Base, Default, Glass, Monochrome, Soft\n\n        theme_mapping = {\n            \"default\": Default,\n            \"soft\": Soft,\n            \"monochrome\": Monochrome,\n            \"glass\": Glass,\n            \"base\": Base,\n        }\n        theme_lower = theme.lower()\n        if theme_lower in theme_mapping:\n            return theme_mapping[theme_lower]()\n        # Try loading from Hub\n        try:\n            return Base.from_hub(theme)\n        except Exception:\n            return Default()\n\n    return theme\n\n\nclass DaggrServer:\n    def __init__(\n        self,\n        graph: Graph,\n        theme: \"Theme | str | None\" = None,\n        api_server: bool = True,\n    ):\n        self.graph = graph\n        self.api_server = api_server\n        self.executor = AsyncExecutor(graph)\n        self.state = SessionState(db_path=os.environ.get(\"DAGGR_DB_PATH\"))\n        self.app = FastAPI(title=graph.name)\n        self.connections: dict[str, WebSocket] = {}\n        self.theme = _get_theme(theme)\n        self.theme_css = self.theme._get_theme_css()\n        self._setup_routes()\n\n    def _extract_token_from_header(self, authorization: str | None) -> str | None:\n        if authorization and authorization.startswith(\"Bearer \"):\n            return authorization[7:]\n        return None\n\n    def _validate_hf_token(self, token: str) -> dict | None:\n        try:\n            from huggingface_hub import whoami\n\n            info = whoami(token=token, cache=True)\n            return {\n                \"username\": info.get(\"name\"),\n                \"fullname\": info.get(\"fullname\"),\n                \"avatar_url\": info.get(\"avatarUrl\"),\n            }\n        except Exception:\n            return None\n\n    def _setup_routes(self):\n        frontend_dir = Path(__file__).parent / \"frontend\" / \"dist\"\n        if not frontend_dir.exists():\n            raise RuntimeError(\n                f\"Frontend not found at {frontend_dir}. \"\n                \"If developing, run 'npm run build' in daggr/frontend/\"\n            )\n\n        @self.app.get(\"/theme.css\", response_class=PlainTextResponse)\n        async def get_theme_css():\n            return PlainTextResponse(self.theme_css, media_type=\"text/css\")\n\n        @self.app.get(\"/api/graph\")\n        async def get_graph():\n            return self._build_graph_data()\n\n        @self.app.get(\"/api/hf_user\")\n        async def get_hf_user():\n            return self._get_hf_user_info()\n\n        @self.app.get(\"/api/user_info\")\n        async def get_user_info(authorization: str | None = Header(default=None)):\n            browser_token = self._extract_token_from_header(authorization)\n            if browser_token:\n                hf_user = self._validate_hf_token(browser_token)\n            else:\n                hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            is_on_spaces = os.environ.get(\"SPACE_ID\") is not None\n            persistence_enabled = self.graph.persist_key is not None\n            return {\n                \"hf_user\": hf_user,\n                \"user_id\": user_id,\n                \"is_on_spaces\": is_on_spaces,\n                \"can_persist\": user_id is not None and persistence_enabled,\n            }\n\n        @self.app.post(\"/api/auth/login\")\n        async def auth_login(request: Request):\n            try:\n                body = await request.json()\n                token = body.get(\"token\")\n                if not token:\n                    return JSONResponse({\"error\": \"Token is required\"}, status_code=400)\n                hf_user = self._validate_hf_token(token)\n                if not hf_user:\n                    return JSONResponse({\"error\": \"Invalid token\"}, status_code=401)\n                return {\"hf_user\": hf_user, \"success\": True}\n            except Exception as e:\n                return JSONResponse({\"error\": str(e)}, status_code=500)\n\n        @self.app.post(\"/api/auth/logout\")\n        async def auth_logout():\n            return {\"success\": True}\n\n        @self.app.get(\"/api/sheets\")\n        async def list_sheets(authorization: str | None = Header(default=None)):\n            if not self.graph.persist_key:\n                return {\"sheets\": [], \"user_id\": None}\n            browser_token = self._extract_token_from_header(authorization)\n            if browser_token:\n                hf_user = self._validate_hf_token(browser_token)\n            else:\n                hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            if not user_id:\n                return JSONResponse(\n                    {\"error\": \"Login required to access sheets on Spaces\"},\n                    status_code=401,\n                )\n            sheets = self.state.list_sheets(user_id, self.graph.persist_key)\n            return {\"sheets\": sheets, \"user_id\": user_id}\n\n        @self.app.post(\"/api/sheets\")\n        async def create_sheet(\n            request: Request, authorization: str | None = Header(default=None)\n        ):\n            if not self.graph.persist_key:\n                return JSONResponse(\n                    {\"error\": \"Persistence is disabled for this graph\"},\n                    status_code=400,\n                )\n            browser_token = self._extract_token_from_header(authorization)\n            if browser_token:\n                hf_user = self._validate_hf_token(browser_token)\n            else:\n                hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            if not user_id:\n                return JSONResponse(\n                    {\"error\": \"Login required to create sheets on Spaces\"},\n                    status_code=401,\n                )\n            body = await request.json()\n            name = body.get(\"name\")\n            sheet_id = self.state.create_sheet(user_id, self.graph.persist_key, name)\n            sheet = self.state.get_sheet(sheet_id)\n            return {\"sheet\": sheet}\n\n        @self.app.patch(\"/api/sheets/{sheet_id}\")\n        async def rename_sheet(\n            sheet_id: str,\n            request: Request,\n            authorization: str | None = Header(default=None),\n        ):\n            browser_token = self._extract_token_from_header(authorization)\n            if browser_token:\n                hf_user = self._validate_hf_token(browser_token)\n            else:\n                hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            if not user_id:\n                return JSONResponse({\"error\": \"Login required\"}, status_code=401)\n            sheet = self.state.get_sheet(sheet_id)\n            if not sheet:\n                return JSONResponse({\"error\": \"Sheet not found\"}, status_code=404)\n            if sheet[\"user_id\"] != user_id:\n                return JSONResponse({\"error\": \"Access denied\"}, status_code=403)\n            body = await request.json()\n            new_name = body.get(\"name\")\n            if not new_name:\n                return JSONResponse({\"error\": \"Name required\"}, status_code=400)\n            self.state.rename_sheet(sheet_id, new_name)\n            return {\"success\": True, \"sheet\": self.state.get_sheet(sheet_id)}\n\n        @self.app.delete(\"/api/sheets/{sheet_id}\")\n        async def delete_sheet(\n            sheet_id: str, authorization: str | None = Header(default=None)\n        ):\n            browser_token = self._extract_token_from_header(authorization)\n            if browser_token:\n                hf_user = self._validate_hf_token(browser_token)\n            else:\n                hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            if not user_id:\n                return JSONResponse({\"error\": \"Login required\"}, status_code=401)\n            sheet = self.state.get_sheet(sheet_id)\n            if not sheet:\n                return JSONResponse({\"error\": \"Sheet not found\"}, status_code=404)\n            if sheet[\"user_id\"] != user_id:\n                return JSONResponse({\"error\": \"Access denied\"}, status_code=403)\n            self.state.delete_sheet(sheet_id)\n            return {\"success\": True}\n\n        @self.app.get(\"/api/sheets/{sheet_id}/state\")\n        async def get_sheet_state(\n            sheet_id: str, authorization: str | None = Header(default=None)\n        ):\n            browser_token = self._extract_token_from_header(authorization)\n            if browser_token:\n                hf_user = self._validate_hf_token(browser_token)\n            else:\n                hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            if not user_id:\n                return JSONResponse({\"error\": \"Login required\"}, status_code=401)\n            sheet = self.state.get_sheet(sheet_id)\n            if not sheet:\n                return JSONResponse({\"error\": \"Sheet not found\"}, status_code=404)\n            if sheet[\"user_id\"] != user_id:\n                return JSONResponse({\"error\": \"Access denied\"}, status_code=403)\n            state = self.state.get_sheet_state(sheet_id)\n            return {\"sheet\": sheet, \"state\": state}\n\n        @self.app.post(\"/api/run/{node_name}\")\n        async def run_to_node(node_name: str, data: dict):\n            session = ExecutionSession(self.graph)\n            session_id = data.get(\"session_id\")\n            input_values = data.get(\"inputs\", {})\n            selected_results = data.get(\"selected_results\", {})\n            return await self._execute_to_node(\n                session, node_name, session_id, input_values, selected_results\n            )\n\n        if self.api_server:\n\n            @self.app.get(\"/api/schema\")\n            async def get_api_schema():\n                return self.graph.get_api_schema()\n\n            @self.app.post(\"/api/call\")\n            async def call_workflow(request: Request):\n                return await self._execute_workflow_api(request, subgraph_id=None)\n\n            @self.app.post(\"/api/call/{subgraph_id}\")\n            async def call_subgraph(subgraph_id: str, request: Request):\n                return await self._execute_workflow_api(\n                    request, subgraph_id=subgraph_id\n                )\n\n        @self.app.websocket(\"/ws/{session_id}\")\n        async def websocket_endpoint(websocket: WebSocket, session_id: str):\n            await websocket.accept()\n            self.connections[session_id] = websocket\n\n            hf_user = self._get_hf_user_info()\n            user_id = self.state.get_effective_user_id(hf_user)\n            current_sheet_id: str | None = None\n\n            session = ExecutionSession(self.graph)\n            running_tasks: dict[str, asyncio.Task] = {}\n\n            async def run_node_execution(\n                node_name: str,\n                sheet_id: str | None,\n                input_values: dict,\n                item_list_values: dict,\n                selected_results: dict,\n                run_id: str,\n                user_id: str | None,\n                run_ancestors: bool = True,\n            ):\n                try:\n                    async for result in self._execute_to_node_streaming(\n                        session,\n                        node_name,\n                        sheet_id,\n                        input_values,\n                        item_list_values,\n                        selected_results,\n                        run_id,\n                        user_id,\n                        run_ancestors,\n                    ):\n                        await websocket.send_json(result)\n                except asyncio.CancelledError:\n                    pass\n                except Exception as e:\n                    await websocket.send_json(\n                        {\n                            \"type\": \"error\",\n                            \"run_id\": run_id,\n                            \"error\": str(e),\n                            \"node\": node_name,\n                        }\n                    )\n\n            try:\n                while True:\n                    data = await websocket.receive_json()\n                    action = data.get(\"action\")\n\n                    if \"hf_token\" in data:\n                        browser_hf_token = data.get(\"hf_token\")\n                        old_user_id = user_id\n                        if browser_hf_token:\n                            hf_user = self._validate_hf_token(browser_hf_token)\n                            user_id = self.state.get_effective_user_id(hf_user)\n                            session.set_hf_token(browser_hf_token)\n                        else:\n                            hf_user = self._get_hf_user_info()\n                            user_id = self.state.get_effective_user_id(hf_user)\n                            session.set_hf_token(None)\n                        if old_user_id != user_id:\n                            session.clear_results()\n                            current_sheet_id = None\n\n                    if action == \"run\":\n                        node_name = data.get(\"node_name\")\n                        input_values = data.get(\"inputs\", {})\n                        item_list_values = data.get(\"item_list_values\", {})\n                        selected_results = data.get(\"selected_results\", {})\n                        run_id = data.get(\"run_id\")\n                        sheet_id = data.get(\"sheet_id\") or current_sheet_id\n                        run_ancestors = data.get(\"run_ancestors\", True)\n\n                        task = asyncio.create_task(\n                            run_node_execution(\n                                node_name,\n                                sheet_id,\n                                input_values,\n                                item_list_values,\n                                selected_results,\n                                run_id,\n                                user_id,\n                                run_ancestors,\n                            )\n                        )\n                        running_tasks[run_id] = task\n                        task.add_done_callback(\n                            lambda t, rid=run_id: running_tasks.pop(rid, None)\n                        )\n\n                    elif action == \"cancel\":\n                        cancel_run_id = data.get(\"run_id\")\n                        cancel_node = data.get(\"node_name\")\n                        task = running_tasks.get(cancel_run_id)\n                        if task:\n                            task.cancel()\n                        await websocket.send_json(\n                            {\n                                \"type\": \"cancelled\",\n                                \"run_id\": cancel_run_id,\n                                \"node\": cancel_node,\n                            }\n                        )\n\n                    elif action == \"get_graph\":\n                        try:\n                            sheet_id = data.get(\"sheet_id\")\n\n                            persisted_inputs = {}\n                            persisted_results: dict[str, list[Any]] = {}\n                            persisted_transform = None\n\n                            if user_id and sheet_id:\n                                sheet = self.state.get_sheet(sheet_id)\n                                if sheet and sheet[\"user_id\"] == user_id:\n                                    current_sheet_id = sheet_id\n                                    state = self.state.get_sheet_state(sheet_id)\n                                    persisted_inputs = state.get(\"inputs\", {})\n                                    persisted_results = state.get(\"results\", {})\n                                    persisted_transform = sheet.get(\"transform\")\n\n                            node_results = {}\n                            for node_name, results_list in persisted_results.items():\n                                if results_list:\n                                    last_entry = results_list[-1]\n                                    if (\n                                        isinstance(last_entry, dict)\n                                        and \"result\" in last_entry\n                                    ):\n                                        node_results[node_name] = last_entry[\"result\"]\n                                    else:\n                                        node_results[node_name] = last_entry\n\n                            graph_data = self._build_graph_data(\n                                node_results=node_results,\n                                input_values=persisted_inputs,\n                            )\n                            graph_data[\"session_id\"] = session_id\n                            graph_data[\"sheet_id\"] = current_sheet_id\n                            graph_data[\"user_id\"] = user_id\n                            graph_data[\"persisted_results\"] = (\n                                self._transform_persisted_results(persisted_results)\n                            )\n                            graph_data[\"transform\"] = persisted_transform\n\n                            await websocket.send_json(\n                                {\"type\": \"graph\", \"data\": graph_data}\n                            )\n                        except Exception as e:\n                            print(f\"[ERROR] get_graph failed: {e}\")\n                            traceback.print_exc()\n                            await websocket.send_json(\n                                {\"type\": \"error\", \"error\": str(e)}\n                            )\n\n                    elif action == \"save_input\":\n                        if user_id and current_sheet_id:\n                            node_id = data.get(\"node_id\")\n                            port_name = data.get(\"port_name\")\n                            value = data.get(\"value\")\n                            if node_id and port_name is not None:\n                                self.state.save_input(\n                                    current_sheet_id, node_id, port_name, value\n                                )\n                                await websocket.send_json(\n                                    {\"type\": \"input_saved\", \"node_id\": node_id}\n                                )\n\n                    elif action == \"save_transform\":\n                        if user_id and current_sheet_id:\n                            x = data.get(\"x\", 0)\n                            y = data.get(\"y\", 0)\n                            scale = data.get(\"scale\", 1)\n                            self.state.save_transform(current_sheet_id, x, y, scale)\n\n                    elif action == \"set_sheet\":\n                        sheet_id = data.get(\"sheet_id\")\n                        if user_id and sheet_id:\n                            sheet = self.state.get_sheet(sheet_id)\n                            if sheet and sheet[\"user_id\"] == user_id:\n                                current_sheet_id = sheet_id\n                                session.clear_results()\n                                await websocket.send_json(\n                                    {\"type\": \"sheet_set\", \"sheet_id\": sheet_id}\n                                )\n\n                    elif action == \"save_variant_selection\":\n                        node_id = data.get(\"node_id\")\n                        variant_index = data.get(\"variant_index\", 0)\n                        if user_id and current_sheet_id and node_id is not None:\n                            self.state.save_input(\n                                current_sheet_id,\n                                node_id,\n                                \"_selected_variant\",\n                                variant_index,\n                            )\n                            await websocket.send_json(\n                                {\n                                    \"type\": \"variant_selection_saved\",\n                                    \"node_id\": node_id,\n                                    \"variant_index\": variant_index,\n                                }\n                            )\n\n                    elif action == \"clear_sheet\":\n                        if user_id and current_sheet_id:\n                            self.state.clear_sheet_data(current_sheet_id)\n                            await websocket.send_json({\"type\": \"sheet_cleared\"})\n\n            except WebSocketDisconnect:\n                for task in running_tasks.values():\n                    task.cancel()\n                if session_id in self.connections:\n                    del self.connections[session_id]\n            except Exception as e:\n                for task in running_tasks.values():\n                    task.cancel()\n                print(f\"[ERROR] WebSocket error: {e}\")\n                traceback.print_exc()\n\n        @self.app.get(\"/\")\n        async def serve_index():\n            index_path = frontend_dir / \"index.html\"\n            if index_path.exists():\n                return FileResponse(index_path)\n            return HTMLResponse(self._get_dev_html())\n\n        @self.app.get(\"/assets/{path:path}\")\n        async def serve_assets(path: str):\n            file_path = frontend_dir / \"assets\" / path\n            if file_path.exists():\n                content_type, _ = mimetypes.guess_type(str(file_path))\n                return FileResponse(file_path, media_type=content_type)\n            return Response(status_code=404)\n\n        @self.app.get(\"/daggr-assets/{path:path}\")\n        async def serve_daggr_assets(path: str):\n            assets_dir = Path(__file__).parent / \"assets\"\n            file_path = assets_dir / path\n            if file_path.exists():\n                content_type, _ = mimetypes.guess_type(str(file_path))\n                return FileResponse(file_path, media_type=content_type)\n            return Response(status_code=404)\n\n        @self.app.get(\"/file/{path:path}\")\n        async def serve_local_file(path: str):\n            if len(path) >= 2 and path[1] == \":\":\n                file_path = Path(path)\n            else:\n                file_path = Path(\"/\") / path\n            temp_dir = Path(tempfile.gettempdir()).resolve()\n            daggr_cache = get_daggr_cache_dir().resolve()\n\n            try:\n                resolved = file_path.resolve()\n                is_allowed = str(resolved).startswith(str(temp_dir)) or str(\n                    resolved\n                ).startswith(str(daggr_cache))\n                if not is_allowed:\n                    return Response(status_code=403)\n            except (ValueError, OSError):\n                return Response(status_code=403)\n            if resolved.exists() and resolved.is_file():\n                content_type, _ = mimetypes.guess_type(str(resolved))\n                return FileResponse(\n                    resolved, media_type=content_type or \"application/octet-stream\"\n                )\n            return Response(status_code=404)\n\n        @self.app.get(\"/{path:path}\")\n        async def serve_static(path: str):\n            if path.startswith(\"api/\") or path.startswith(\"ws/\"):\n                return Response(status_code=404)\n            file_path = frontend_dir / path\n            if file_path.exists() and file_path.is_file():\n                return FileResponse(file_path)\n            index_path = frontend_dir / \"index.html\"\n            if index_path.exists():\n                return FileResponse(index_path)\n            return HTMLResponse(self._get_dev_html())\n\n    def _get_dev_html(self) -> str:\n        return f\"\"\"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{self.graph.name}</title>\n    <link rel=\"stylesheet\" href=\"/theme.css\">\n    <style>\n        * {{ margin: 0; box-sizing: border-box; }}\n        body {{\n            background: var(--body-background-fill, #000);\n            min-height: 100vh;\n            font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;\n            overflow: hidden;\n            color: var(--body-text-color, #fff);\n        }}\n    </style>\n    <script type=\"module\" src=\"http://localhost:5173/src/main.ts\"></script>\n</head>\n<body class=\"dark\">\n    <div id=\"app\"></div>\n</body>\n</html>\"\"\"\n\n    def _get_node_url(self, node) -> str | None:\n        if isinstance(node, GradioNode):\n            src = node._src\n            if src.startswith(\"http://\") or src.startswith(\"https://\"):\n                return src\n            elif \"/\" in src:\n                return f\"https://huggingface.co/spaces/{src}\"\n        elif isinstance(node, InferenceNode):\n            return f\"https://huggingface.co/{node._model_name_for_hub}\"\n        return None\n\n    def _get_node_type(self, node, node_name: str) -> str:\n        type_map = {\n            \"FnNode\": \"FN\",\n            \"TextInput\": \"INPUT\",\n            \"ImageInput\": \"IMAGE\",\n            \"ChooseOne\": \"SELECT\",\n            \"Approve\": \"APPROVE\",\n            \"GradioNode\": \"GRADIO\",\n            \"InferenceNode\": \"MODEL\",\n            \"InteractionNode\": \"ACTION\",\n            \"ChoiceNode\": \"CHOICE\",\n        }\n        if isinstance(node, ChoiceNode):\n            return \"CHOICE\"\n        if isinstance(node, InputNode):\n            return \"INPUT\"\n        class_name = node.__class__.__name__\n        return type_map.get(class_name, class_name.upper())\n\n    def _has_scattered_input(self, node_name: str) -> bool:\n        for edge in self.graph._edges:\n            if edge.target_node._name == node_name and edge.is_scattered:\n                return True\n        return False\n\n    def _get_scattered_edge(self, node_name: str):\n        for edge in self.graph._edges:\n            if edge.target_node._name == node_name and edge.is_scattered:\n                return edge\n        return None\n\n    def _is_output_node(self, node_name: str) -> bool:\n        return self.graph._nx_graph.out_degree(node_name) == 0\n\n    def _is_running_locally(self, node) -> bool:\n        if not isinstance(node, GradioNode):\n            return False\n        return bool(node._run_locally and node._local_url and not node._local_failed)\n\n    def _build_variant_data(self, variant, input_values: dict) -> dict[str, Any]:\n        variant_name = variant._name\n        if isinstance(variant, GradioNode) and not variant._name_explicitly_set:\n            variant_name = f\"{variant._src}\"\n            if variant._api_name:\n                variant_name += f\" ({variant._api_name})\"\n\n        input_components = []\n        for port_name, comp in variant._input_components.items():\n            comp_data = self._serialize_component(comp, port_name)\n            input_components.append(comp_data)\n\n        output_components = []\n        for port_name, comp in variant._output_components.items():\n            if comp is None:\n                continue\n            visible = getattr(comp, \"visible\", True)\n            if visible is False:\n                continue\n            comp_data = self._serialize_component(comp, port_name)\n            output_components.append(comp_data)\n\n        return {\n            \"name\": variant_name,\n            \"input_components\": input_components,\n            \"output_components\": output_components,\n        }\n\n    def _get_component_type(self, component) -> str:\n        class_name = component.__class__.__name__\n        type_map = {\n            \"Audio\": \"audio\",\n            \"Textbox\": \"textbox\",\n            \"TextArea\": \"textarea\",\n            \"JSON\": \"json\",\n            \"Chatbot\": \"json\",\n            \"Image\": \"image\",\n            \"Number\": \"number\",\n            \"Markdown\": \"markdown\",\n            \"Text\": \"text\",\n            \"Dropdown\": \"dropdown\",\n            \"Video\": \"video\",\n            \"File\": \"file\",\n            \"Model3D\": \"model3d\",\n            \"Gallery\": \"gallery\",\n            \"Slider\": \"slider\",\n            \"Radio\": \"radio\",\n            \"Checkbox\": \"checkbox\",\n            \"CheckboxGroup\": \"checkboxgroup\",\n            \"ColorPicker\": \"colorpicker\",\n            \"Label\": \"label\",\n            \"HighlightedText\": \"highlightedtext\",\n            \"Code\": \"code\",\n            \"HTML\": \"html\",\n            \"Dataframe\": \"dataframe\",\n        }\n        return type_map.get(class_name, \"text\")\n\n    def _serialize_component(self, comp, port_name: str) -> dict[str, Any]:\n        comp_type = self._get_component_type(comp)\n        comp_class = comp.__class__.__name__\n\n        props = {\n            \"label\": getattr(comp, \"label\", \"\") or port_name,\n            \"show_label\": bool(getattr(comp, \"label\", \"\")),\n            \"interactive\": getattr(comp, \"interactive\", True),\n            \"visible\": getattr(comp, \"visible\", True),\n        }\n\n        if hasattr(comp, \"placeholder\"):\n            props[\"placeholder\"] = comp.placeholder\n        if hasattr(comp, \"lines\"):\n            props[\"lines\"] = comp.lines\n        if hasattr(comp, \"max_lines\"):\n            props[\"max_lines\"] = comp.max_lines\n        if hasattr(comp, \"type\"):\n            props[\"type\"] = comp.type\n        if hasattr(comp, \"choices\") and comp.choices:\n            choices = []\n            for c in comp.choices:\n                if isinstance(c, (tuple, list)) and len(c) >= 2:\n                    choices.append([c[0], c[1]])\n                else:\n                    choices.append([str(c), c])\n            props[\"choices\"] = choices\n        if hasattr(comp, \"minimum\"):\n            props[\"minimum\"] = comp.minimum\n        if hasattr(comp, \"maximum\"):\n            props[\"maximum\"] = comp.maximum\n        if hasattr(comp, \"step\"):\n            props[\"step\"] = comp.step\n\n        value = getattr(comp, \"value\", None)\n        if is_file_obj_with_meta(value):\n            value = self._file_to_url(value[\"path\"])\n\n        return {\n            \"component\": comp_class.lower(),\n            \"type\": comp_type,\n            \"port_name\": port_name,\n            \"props\": props,\n            \"value\": value,\n        }\n\n    def _file_to_url(self, value: Any) -> Any:\n        if isinstance(value, str) and not value.startswith(\"/file/\"):\n            path = Path(value)\n            if path.is_absolute() and path.exists():\n                normalized = value.replace(\"\\\\\", \"/\")\n                if normalized.startswith(\"/\"):\n                    return f\"/file{normalized}\"\n                return f\"/file/{normalized}\"\n        return value\n\n    def _validate_file_value(self, value: Any, comp_type: str) -> str | None:\n        \"\"\"Validate that a value is appropriate for a file-type component.\n        Returns an error message if invalid, None if valid.\"\"\"\n        if value is None:\n            return None\n        if isinstance(value, str):\n            return None\n        if isinstance(value, dict):\n            if \"url\" in value or \"path\" in value:\n                return None\n            keys = list(value.keys())\n            if keys:\n                return (\n                    f\"Expected a file path string for {comp_type}, but got a dict \"\n                    f\"with keys {keys}. If using postprocess, extract the path: \"\n                    f\"e.g., `postprocess=lambda x: x['{keys[0]}']`\"\n                )\n            return (\n                f\"Expected a file path string for {comp_type}, but got an empty dict.\"\n            )\n        return f\"Expected a file path string for {comp_type}, but got {type(value).__name__}.\"\n\n    def _transform_file_paths(self, data: Any) -> Any:\n        if isinstance(data, str):\n            return self._file_to_url(data)\n        elif isinstance(data, dict):\n            return {k: self._transform_file_paths(v) for k, v in data.items()}\n        elif isinstance(data, list):\n            return [self._transform_file_paths(item) for item in data]\n        return data\n\n    def _transform_persisted_results(\n        self, persisted_results: dict[str, list[Any]]\n    ) -> dict[str, list[Any]]:\n        \"\"\"Transform persisted results, handling both old format (just result)\n        and new format (dict with result and inputs_snapshot).\"\"\"\n        transformed: dict[str, list[Any]] = {}\n        for node_name, results_list in persisted_results.items():\n            transformed[node_name] = []\n            for entry in results_list:\n                if isinstance(entry, dict) and \"result\" in entry:\n                    transformed[node_name].append(\n                        {\n                            \"result\": self._transform_file_paths(entry[\"result\"]),\n                            \"inputs_snapshot\": entry.get(\"inputs_snapshot\"),\n                        }\n                    )\n                else:\n                    transformed[node_name].append(self._transform_file_paths(entry))\n        return transformed\n\n    def _build_input_components(self, node) -> list[dict[str, Any]]:\n        if not node._input_components:\n            return []\n        return [\n            self._serialize_component(comp, port_name)\n            for port_name, comp in node._input_components.items()\n        ]\n\n    def _build_output_components(\n        self, node, result: Any = None\n    ) -> tuple[list[dict[str, Any]], str | None]:\n        if not node._output_components:\n            return [], None\n\n        components = []\n        validation_error = None\n        for port_name, comp in node._output_components.items():\n            if comp is None:\n                continue\n\n            visible = getattr(comp, \"visible\", True)\n            if visible is False:\n                continue\n\n            comp_data = self._serialize_component(comp, port_name)\n            comp_type = self._get_component_type(comp)\n            if result is not None:\n                if isinstance(result, dict):\n                    value = result.get(\n                        port_name, result.get(comp_data[\"props\"][\"label\"])\n                    )\n                else:\n                    value = result\n                if comp_type in _FILE_COMP_TYPES:\n                    error = self._validate_file_value(value, comp_type)\n                    if error and validation_error is None:\n                        validation_error = error\n                    value = self._file_to_url(value)\n                comp_data[\"value\"] = value\n            components.append(comp_data)\n        return components, validation_error\n\n    def _build_scattered_items(\n        self, node_name: str, result: Any = None\n    ) -> list[dict[str, Any]]:\n        scattered_edge = self._get_scattered_edge(node_name)\n        if not scattered_edge:\n            return []\n\n        node = self.graph.nodes[node_name]\n        item_output_type = \"text\"\n        for comp in node._output_components.values():\n            if comp is None:\n                continue\n            comp_type = self._get_component_type(comp)\n            if comp_type == \"audio\":\n                item_output_type = \"audio\"\n                break\n\n        items = []\n        if result and isinstance(result, dict) and \"_scattered_results\" in result:\n            results = result[\"_scattered_results\"]\n            source_items = result.get(\"_items\", [])\n            for i, item_result in enumerate(results):\n                source_item = source_items[i] if i < len(source_items) else None\n                preview = \"\"\n                output = None\n\n                if isinstance(source_item, dict):\n                    preview_parts = [\n                        f\"{k}: {str(v)[:20]}\" for k, v in list(source_item.items())[:2]\n                    ]\n                    preview = \", \".join(preview_parts)\n                elif source_item:\n                    preview = str(source_item)[:50]\n\n                if isinstance(item_result, dict):\n                    first_key = list(item_result.keys())[0] if item_result else None\n                    if first_key:\n                        output = item_result[first_key]\n                else:\n                    output = item_result\n\n                if output:\n                    output = str(output)\n\n                items.append(\n                    {\n                        \"index\": i + 1,\n                        \"preview\": preview or f\"Item {i + 1}\",\n                        \"output\": output,\n                        \"is_audio_output\": item_output_type == \"audio\",\n                    }\n                )\n        return items\n\n    def _serialize_item_list_schema(\n        self, schema: dict[str, Any]\n    ) -> list[dict[str, Any]]:\n        serialized = []\n        for field_name, comp in schema.items():\n            comp_data = self._serialize_component(comp, field_name)\n            serialized.append(comp_data)\n        return serialized\n\n    def _build_item_list_items(\n        self, node, port_name: str, result: Any = None\n    ) -> list[dict[str, Any]]:\n        schema = node._item_list_schemas.get(port_name, {})\n        if not schema:\n            return []\n\n        items = []\n        if result and isinstance(result, dict) and port_name in result:\n            item_list = result[port_name]\n            if isinstance(item_list, list):\n                for i, item_data in enumerate(item_list):\n                    item = {\"index\": i, \"fields\": {}}\n                    if isinstance(item_data, dict):\n                        for field_name in schema:\n                            item[\"fields\"][field_name] = item_data.get(field_name)\n                    items.append(item)\n        return items\n\n    def _apply_item_list_edits(\n        self, node_name: str, result: Any, item_list_values: dict\n    ) -> Any:\n        node = self.graph.nodes[node_name]\n        if not node._item_list_schemas:\n            return result\n\n        node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n        edits = item_list_values.get(node_id, {})\n        if not edits:\n            return result\n\n        first_port = list(node._item_list_schemas.keys())[0]\n        if isinstance(result, dict) and first_port in result:\n            items = result[first_port]\n            if isinstance(items, list):\n                for idx_str, field_edits in edits.items():\n                    idx = int(idx_str)\n                    if 0 <= idx < len(items) and isinstance(items[idx], dict):\n                        items[idx].update(field_edits)\n        return result\n\n    def _compute_node_depths(self) -> dict[str, int]:\n        depths: dict[str, int] = {}\n        connections = self.graph.get_connections()\n\n        for node_name in self.graph.nodes:\n            if self.graph._nx_graph.in_degree(node_name) == 0:\n                depths[node_name] = 0\n\n        changed = True\n        while changed:\n            changed = False\n            for source, _, target, _ in connections:\n                if source in depths:\n                    new_depth = depths[source] + 1\n                    if target not in depths or depths[target] < new_depth:\n                        depths[target] = new_depth\n                        changed = True\n\n        for node_name in self.graph.nodes:\n            if node_name not in depths:\n                depths[node_name] = 0\n\n        return depths\n\n    def _get_hf_user_info(self) -> dict | None:\n        try:\n            from huggingface_hub import get_token, whoami\n\n            token = get_token()\n            if not token:\n                return None\n\n            info = whoami(cache=True)\n            return {\n                \"username\": info.get(\"name\"),\n                \"fullname\": info.get(\"fullname\"),\n                \"avatar_url\": info.get(\"avatarUrl\"),\n            }\n        except Exception:\n            return None\n\n    def _build_graph_data(\n        self,\n        node_results: dict[str, Any] | None = None,\n        node_statuses: dict[str, str] | None = None,\n        input_values: dict[str, Any] | None = None,\n        history: dict[str, dict[str, list[dict]]] | None = None,\n        session_id: str | None = None,\n        selected_results: dict[str, int] | None = None,\n    ) -> dict:\n        node_results = node_results or {}\n        node_statuses = node_statuses or {}\n        input_values = input_values or {}\n        history = history or {}\n        selected_results = selected_results or {}\n\n        depths = self._compute_node_depths()\n\n        synthetic_input_nodes: list[dict[str, Any]] = []\n        synthetic_edges: list[dict[str, Any]] = []\n        input_node_positions: dict[str, tuple] = {}\n\n        component_to_input_node: dict[int, str] = {}\n        creation_order = 0\n        for node_name in self.graph.nodes:\n            node = self.graph.nodes[node_name]\n\n            if isinstance(node, ChoiceNode):\n                continue\n\n            if isinstance(node, InputNode):\n                continue\n\n            if node._input_components:\n                for idx, (port_name, comp) in enumerate(node._input_components.items()):\n                    comp_id = id(comp)\n\n                    if comp_id in component_to_input_node:\n                        existing_input_node = component_to_input_node[comp_id]\n                        existing_input_id = existing_input_node.replace(\n                            \" \", \"_\"\n                        ).replace(\"-\", \"_\")\n                        synthetic_edges.append(\n                            {\n                                \"from_node\": existing_input_id,\n                                \"from_port\": \"value\",\n                                \"to_node\": node_name.replace(\" \", \"_\").replace(\n                                    \"-\", \"_\"\n                                ),\n                                \"to_port\": port_name,\n                            }\n                        )\n                        continue\n\n                    input_node_name = f\"{node_name}__{port_name}\"\n                    input_node_id = input_node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                    component_to_input_node[comp_id] = input_node_name\n\n                    comp_data = self._serialize_component(comp, \"value\")\n                    label = comp_data[\"props\"].get(\"label\") or port_name\n\n                    if input_node_id in input_values:\n                        comp_data[\"value\"] = input_values[input_node_id].get(\n                            \"value\", comp_data[\"value\"]\n                        )\n\n                    synthetic_input_nodes.append(\n                        {\n                            \"node_name\": input_node_name,\n                            \"display_name\": label,\n                            \"target_node\": node_name,\n                            \"target_port\": port_name,\n                            \"component\": comp_data,\n                            \"index\": idx,\n                            \"creation_order\": creation_order,\n                        }\n                    )\n                    creation_order += 1\n\n                    synthetic_edges.append(\n                        {\n                            \"from_node\": input_node_id,\n                            \"from_port\": \"value\",\n                            \"to_node\": node_name.replace(\" \", \"_\").replace(\"-\", \"_\"),\n                            \"to_port\": port_name,\n                        }\n                    )\n\n        max_depth = max(depths.values()) if depths else 0\n\n        nodes_by_depth: dict[int, list[str]] = {}\n        for node_name, depth in depths.items():\n            if depth not in nodes_by_depth:\n                nodes_by_depth[depth] = []\n            nodes_by_depth[depth].append(node_name)\n\n        x_spacing = 350\n        input_column_x = 50\n        x_start = 400\n        y_start = 120\n        y_gap = 30\n        base_node_height = 100\n        component_base_height = 60\n        line_height = 18\n\n        def calc_component_height(comp_data: dict) -> int:\n            lines = comp_data.get(\"props\", {}).get(\"lines\", 1)\n            lines = min(lines, 6)\n            return component_base_height + max(0, lines - 1) * line_height\n\n        def calc_node_height(components: list[dict], num_ports: int = 1) -> int:\n            comp_height = sum(calc_component_height(c) for c in components)\n            port_height = max(num_ports, 1) * 22\n            return base_node_height + port_height + comp_height\n\n        all_input_nodes_sorted: list[dict] = []\n        for syn_node in synthetic_input_nodes:\n            target_depth = depths.get(syn_node[\"target_node\"], 0)\n            all_input_nodes_sorted.append({**syn_node, \"target_depth\": target_depth})\n        all_input_nodes_sorted.sort(key=lambda x: x[\"creation_order\"])\n\n        current_input_y = y_start\n        for syn_node in all_input_nodes_sorted:\n            input_node_positions[syn_node[\"node_name\"]] = (\n                input_column_x,\n                current_input_y,\n            )\n            node_height = calc_node_height([syn_node[\"component\"]], 1)\n            current_input_y += node_height + y_gap\n\n        node_positions: dict[str, tuple] = {}\n        for depth in range(max_depth + 1):\n            depth_nodes = nodes_by_depth.get(depth, [])\n            current_y = y_start\n            for node_name in depth_nodes:\n                node = self.graph.nodes[node_name]\n                output_comps, _ = self._build_output_components(node)\n                num_ports = max(\n                    len(node._input_ports or []), len(node._output_ports or [])\n                )\n                node_height = calc_node_height(output_comps, num_ports)\n                x = x_start + depth * x_spacing\n                node_positions[node_name] = (x, current_y)\n                current_y += node_height + y_gap\n\n        nodes = []\n\n        for syn_node in synthetic_input_nodes:\n            node_name = syn_node[\"node_name\"]\n            display_name = syn_node[\"display_name\"]\n            node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n            x, y = input_node_positions.get(node_name, (50, 50))\n            comp = syn_node[\"component\"]\n\n            nodes.append(\n                {\n                    \"id\": node_id,\n                    \"name\": display_name,\n                    \"type\": \"INPUT\",\n                    \"inputs\": [],\n                    \"outputs\": [\"value\"],\n                    \"x\": x,\n                    \"y\": y,\n                    \"has_input\": False,\n                    \"input_value\": \"\",\n                    \"input_components\": [comp],\n                    \"output_components\": [],\n                    \"is_map_node\": False,\n                    \"map_items\": [],\n                    \"map_item_count\": 0,\n                    \"item_output_type\": \"text\",\n                    \"status\": \"pending\",\n                    \"result\": \"\",\n                    \"is_output_node\": False,\n                    \"is_input_node\": True,\n                }\n            )\n\n        for node_name in self.graph.nodes:\n            node = self.graph.nodes[node_name]\n            x, y = node_positions.get(node_name, (50, 50))\n\n            result = node_results.get(node_name)\n            result_str = \"\"\n            is_scattered = self._has_scattered_input(node_name)\n            if result is not None and not node._output_components and not is_scattered:\n                if isinstance(result, dict):\n                    display_result = {\n                        k: v for k, v in result.items() if not k.startswith(\"_\")\n                    }\n                    result_str = json.dumps(display_result, indent=2, default=str)[:300]\n                elif isinstance(result, (list, tuple)):\n                    result_str = json.dumps(list(result)[:5], default=str)\n                else:\n                    result_str = str(result)[:300]\n\n            node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n\n            input_ports_data = []\n            for port in node._input_ports or []:\n                if port in node._fixed_inputs:\n                    continue\n                port_history = history.get(node_name, {}).get(port, [])\n                input_ports_data.append(\n                    {\n                        \"name\": port,\n                        \"history_count\": len(port_history) if port_history else 0,\n                    }\n                )\n\n            output_components, validation_error = self._build_output_components(\n                node, result\n            )\n            scattered_items = (\n                self._build_scattered_items(node_name, result) if is_scattered else []\n            )\n\n            item_output_type = \"text\"\n            if is_scattered:\n                for comp in node._output_components.values():\n                    if comp is None:\n                        continue\n                    comp_type = self._get_component_type(comp)\n                    if comp_type == \"audio\":\n                        item_output_type = \"audio\"\n                        break\n\n            item_list_schema = None\n            item_list_items = []\n            if node._item_list_schemas:\n                first_port = list(node._item_list_schemas.keys())[0]\n                item_list_schema = self._serialize_item_list_schema(\n                    node._item_list_schemas[first_port]\n                )\n                item_list_items = self._build_item_list_items(node, first_port, result)\n\n            output_ports = []\n            for port_name in node._output_ports or []:\n                if port_name in node._item_list_schemas:\n                    schema = node._item_list_schemas[port_name]\n                    for field_name in schema:\n                        output_ports.append(f\"{port_name}.{field_name}\")\n                elif port_name in node._output_components or isinstance(\n                    node, InputNode\n                ):\n                    output_ports.append(port_name)\n\n            is_output = self._is_output_node(node_name)\n            is_local = self._is_running_locally(node)\n\n            variants = None\n            selected_variant = None\n            if isinstance(node, ChoiceNode):\n                variants = [\n                    self._build_variant_data(v, input_values) for v in node._variants\n                ]\n                selected_variant = input_values.get(node_id, {}).get(\n                    \"_selected_variant\", 0\n                )\n\n            is_input_node = isinstance(node, InputNode)\n            node_type = self._get_node_type(node, node_name)\n\n            embedded_components = []\n            output_components, validation_error = [], None\n\n            if is_input_node:\n                embedded_components = self._build_input_components(node)\n            else:\n                result = node_results.get(node_name)\n                output_components, validation_error = self._build_output_components(\n                    node, result\n                )\n                embedded_components = output_components\n\n            nodes.append(\n                {\n                    \"id\": node_id,\n                    \"name\": node_name,\n                    \"type\": node_type,\n                    \"url\": self._get_node_url(node),\n                    \"inputs\": input_ports_data,\n                    \"outputs\": output_ports,\n                    \"x\": x,\n                    \"y\": y,\n                    \"has_input\": False,\n                    \"input_value\": input_values.get(node_name, \"\"),\n                    \"input_components\": embedded_components if is_input_node else [],\n                    \"output_components\": output_components,\n                    \"is_map_node\": is_scattered,\n                    \"map_items\": scattered_items,\n                    \"map_item_count\": len(scattered_items),\n                    \"item_output_type\": item_output_type,\n                    \"item_list_schema\": item_list_schema,\n                    \"item_list_items\": item_list_items,\n                    \"status\": node_statuses.get(node_name, \"pending\"),\n                    \"result\": result_str,\n                    \"is_output_node\": is_output,\n                    \"is_input_node\": is_input_node,\n                    \"is_local\": is_local,\n                    \"variants\": variants,\n                    \"selected_variant\": selected_variant,\n                    \"validation_error\": validation_error,\n                }\n            )\n\n        edges = []\n        for i, edge in enumerate(self.graph._edges):\n            from_port = edge.source_port\n            if edge.item_key:\n                from_port = f\"{edge.source_port}.{edge.item_key}\"\n            edges.append(\n                {\n                    \"id\": f\"edge_{i}\",\n                    \"from_node\": edge.source_node._name.replace(\" \", \"_\").replace(\n                        \"-\", \"_\"\n                    ),\n                    \"from_port\": from_port,\n                    \"to_node\": edge.target_node._name.replace(\" \", \"_\").replace(\n                        \"-\", \"_\"\n                    ),\n                    \"to_port\": edge.target_port,\n                    \"is_scattered\": edge.is_scattered,\n                    \"is_gathered\": edge.is_gathered,\n                }\n            )\n\n        for i, syn_edge in enumerate(synthetic_edges):\n            edges.append(\n                {\n                    \"id\": f\"input_edge_{i}\",\n                    \"from_node\": syn_edge[\"from_node\"],\n                    \"from_port\": syn_edge[\"from_port\"],\n                    \"to_node\": syn_edge[\"to_node\"],\n                    \"to_port\": syn_edge[\"to_port\"],\n                }\n            )\n\n        return {\n            \"name\": self.graph.name,\n            \"nodes\": nodes,\n            \"edges\": edges,\n            \"inputs\": input_values,\n            \"selected_results\": selected_results,\n            \"history\": history,\n            \"session_id\": session_id,\n        }\n\n    def _get_ancestors(self, node_name: str) -> list[str]:\n        ancestors = set()\n        to_visit = [node_name]\n        while to_visit:\n            current = to_visit.pop()\n            for source, _, target, _ in self.graph.get_connections():\n                if target == current and source not in ancestors:\n                    ancestors.add(source)\n                    to_visit.append(source)\n        return list(ancestors)\n\n    def _get_user_provided_output(\n        self, node, node_id: str, input_values: dict[str, Any]\n    ) -> dict[str, Any] | None:\n        if not node._output_components:\n            return None\n\n        node_inputs = input_values.get(node_id, {})\n        if not node_inputs:\n            return None\n\n        result = {}\n        has_user_value = False\n        for port_name, comp in node._output_components.items():\n            if comp is None:\n                continue\n            if port_name in node_inputs:\n                value = node_inputs[port_name]\n                if value is not None:\n                    if isinstance(value, str) and value.startswith(\"data:\"):\n                        value = self._save_data_url_as_gradio_file(value)\n                    result[port_name] = value\n                    has_user_value = True\n\n        return result if has_user_value else None\n\n    def _save_data_url_as_gradio_file(self, data_url: str):\n        try:\n            header, data = data_url.split(\",\", 1)\n            mime_type = header.split(\":\")[1].split(\";\")[0]\n            ext_map = {\n                \"image/png\": \".png\",\n                \"image/jpeg\": \".jpg\",\n                \"image/gif\": \".gif\",\n                \"image/webp\": \".webp\",\n                \"audio/webm\": \".webm\",\n                \"audio/wav\": \".wav\",\n                \"audio/mp3\": \".mp3\",\n                \"audio/mpeg\": \".mp3\",\n            }\n            ext = ext_map.get(mime_type, \".bin\")\n            file_data = base64.b64decode(data)\n            temp_dir = Path(tempfile.gettempdir()) / \"daggr_uploads\"\n            temp_dir.mkdir(exist_ok=True)\n            file_path = temp_dir / f\"{uuid.uuid4()}{ext}\"\n            file_path.write_bytes(file_data)\n            return FileValue(str(file_path))\n        except Exception as e:\n            print(f\"[ERROR] Failed to save data URL: {e}\")\n            return data_url\n\n    def _convert_urls_to_file_values(self, data: Any) -> Any:\n        if isinstance(data, str):\n            if data.startswith((\"http://\", \"https://\", \"/\")) and any(\n                data.lower().endswith(ext)\n                for ext in (\n                    \".png\",\n                    \".jpg\",\n                    \".jpeg\",\n                    \".gif\",\n                    \".webp\",\n                    \".wav\",\n                    \".mp3\",\n                    \".webm\",\n                    \".mp4\",\n                    \".ogg\",\n                )\n            ):\n                return FileValue(data)\n            return data\n        elif isinstance(data, dict):\n            return {k: self._convert_urls_to_file_values(v) for k, v in data.items()}\n        elif isinstance(data, list):\n            return [self._convert_urls_to_file_values(item) for item in data]\n        return data\n\n    async def _execute_to_node(\n        self,\n        session: ExecutionSession,\n        target_node: str,\n        session_id: str | None,\n        input_values: dict[str, Any],\n        selected_results: dict[str, int],\n    ) -> dict:\n        if not session_id:\n            session_id = self.state.create_session(self.graph.persist_key)\n\n        for node_name, node in self.graph.nodes.items():\n            if isinstance(node, ChoiceNode):\n                node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                variant_idx = input_values.get(node_id, {}).get(\"_selected_variant\", 0)\n                session.selected_variants[node_name] = variant_idx\n\n        ancestors = self._get_ancestors(target_node)\n        nodes_to_run = ancestors + [target_node]\n        execution_order = self.graph.get_execution_order()\n        nodes_to_execute = [n for n in execution_order if n in nodes_to_run]\n\n        entry_inputs: dict[str, dict[str, Any]] = {}\n        for node_name in nodes_to_execute:\n            node = self.graph.nodes[node_name]\n\n            if node._input_components:\n                node_inputs = {}\n                for port_name in node._input_components:\n                    input_node_name = f\"{node_name}__{port_name}\"\n                    input_node_id = input_node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                    if input_node_id in input_values:\n                        value = input_values[input_node_id].get(\"value\")\n                        if value is not None:\n                            node_inputs[port_name] = value\n\n                    current_node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                    if current_node_id in input_values:\n                        if port_name in input_values[current_node_id]:\n                            value = input_values[current_node_id][port_name]\n                            if value is not None:\n                                node_inputs[port_name] = value\n\n                if node_inputs:\n                    entry_inputs[node_name] = node_inputs\n\n            elif isinstance(node, InteractionNode):\n                value = input_values.get(node_name, \"\")\n                port = node._input_ports[0] if node._input_ports else \"input\"\n                entry_inputs[node_name] = {port: value}\n\n        existing_results = {}\n        if session_id:\n            for node_name in nodes_to_execute:\n                if node_name in selected_results:\n                    cached = self.state.get_result_by_index(\n                        session_id, node_name, selected_results[node_name]\n                    )\n                else:\n                    cached = self.state.get_latest_result(session_id, node_name)\n                if cached is not None:\n                    existing_results[node_name] = self._convert_urls_to_file_values(\n                        cached\n                    )\n\n        for k, v in existing_results.items():\n            if k not in session.results:\n                session.results[k] = v\n\n        if target_node in session.results:\n            del session.results[target_node]\n\n        node_results = {}\n        node_statuses = {}\n\n        for node_name in nodes_to_execute:\n            if node_name in existing_results:\n                node_results[node_name] = existing_results[node_name]\n                node_statuses[node_name] = \"completed\"\n                continue\n\n            if node_name in session.results:\n                node_results[node_name] = session.results[node_name]\n                node_statuses[node_name] = \"completed\"\n                continue\n\n            node_statuses[node_name] = \"running\"\n            user_input = entry_inputs.get(node_name, {})\n            result = await self.executor.execute_node(session, node_name, user_input)\n            node_results[node_name] = result\n            node_statuses[node_name] = \"completed\"\n            self.state.save_result(session_id, node_name, result)\n\n        return self._build_graph_data(\n            node_results, node_statuses, input_values, {}, session_id, selected_results\n        )\n\n    async def _execute_to_node_streaming(\n        self,\n        session: ExecutionSession,\n        target_node: str,\n        sheet_id: str | None,\n        input_values: dict[str, Any],\n        item_list_values: dict[str, Any],\n        selected_results: dict[str, int],\n        run_id: str,\n        user_id: str | None = None,\n        run_ancestors: bool = True,\n    ):\n        can_persist = (\n            user_id is not None\n            and sheet_id is not None\n            and self.graph.persist_key is not None\n        )\n\n        for node_name, node in self.graph.nodes.items():\n            if isinstance(node, ChoiceNode):\n                node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                variant_idx = input_values.get(node_id, {}).get(\"_selected_variant\", 0)\n                session.selected_variants[node_name] = variant_idx\n\n        if run_ancestors:\n            ancestors = self._get_ancestors(target_node)\n            nodes_to_run = ancestors + [target_node]\n        else:\n            nodes_to_run = [target_node]\n        execution_order = self.graph.get_execution_order()\n        nodes_to_execute = [n for n in execution_order if n in nodes_to_run]\n\n        entry_inputs: dict[str, dict[str, Any]] = {}\n        for node_name in nodes_to_execute:\n            node = self.graph.nodes[node_name]\n\n            if node._input_components:\n                node_inputs = {}\n                for port_name in node._input_components:\n                    input_node_name = f\"{node_name}__{port_name}\"\n                    input_node_id = input_node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                    if input_node_id in input_values:\n                        value = input_values[input_node_id].get(\"value\")\n                        if value is not None:\n                            node_inputs[port_name] = value\n\n                    current_node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                    if current_node_id in input_values:\n                        if port_name in input_values[current_node_id]:\n                            value = input_values[current_node_id][port_name]\n                            if value is not None:\n                                node_inputs[port_name] = value\n\n                if node_inputs:\n                    entry_inputs[node_name] = node_inputs\n\n            elif isinstance(node, InteractionNode):\n                value = input_values.get(node_name, \"\")\n                port = node._input_ports[0] if node._input_ports else \"input\"\n                entry_inputs[node_name] = {port: value}\n\n        existing_results = {}\n        for node_name in nodes_to_execute:\n            node = self.graph.nodes[node_name]\n            node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n            user_output = self._get_user_provided_output(node, node_id, input_values)\n            if user_output is not None:\n                existing_results[node_name] = user_output\n                if can_persist:\n                    snapshot = {\n                        \"inputs\": input_values,\n                        \"selected_results\": selected_results,\n                    }\n                    self.state.save_result(sheet_id, node_name, user_output, snapshot)\n                continue\n\n            if node_name == target_node:\n                continue\n\n            if can_persist:\n                if node_name in selected_results:\n                    cached = self.state.get_result_by_index(\n                        sheet_id, node_name, selected_results[node_name]\n                    )\n                else:\n                    cached = self.state.get_latest_result(sheet_id, node_name)\n                if cached is not None:\n                    existing_results[node_name] = self._convert_urls_to_file_values(\n                        cached\n                    )\n\n        for k, v in existing_results.items():\n            if k not in session.results:\n                session.results[k] = v\n\n        if target_node in session.results:\n            del session.results[target_node]\n\n        node_results = {}\n        node_statuses = {}\n\n        try:\n            for node_name in nodes_to_execute:\n                if node_name in existing_results:\n                    result = existing_results[node_name]\n                    result = self._apply_item_list_edits(\n                        node_name, result, item_list_values\n                    )\n                    node_results[node_name] = result\n                    session.results[node_name] = result\n                    node_statuses[node_name] = \"completed\"\n                    continue\n\n                if node_name in session.results:\n                    result = session.results[node_name]\n                    result = self._apply_item_list_edits(\n                        node_name, result, item_list_values\n                    )\n                    node_results[node_name] = result\n                    node_statuses[node_name] = \"completed\"\n                    continue\n\n                can_execute = await session.start_node_execution(node_name)\n                if not can_execute:\n                    if node_name == target_node:\n                        return\n                    await session.wait_for_node(node_name)\n                    if node_name in session.results:\n                        result = session.results[node_name]\n                        result = self._apply_item_list_edits(\n                            node_name, result, item_list_values\n                        )\n                        node_results[node_name] = result\n                        node_statuses[node_name] = \"completed\"\n                        continue\n\n                try:\n                    node_statuses[node_name] = \"running\"\n                    user_input = entry_inputs.get(node_name, {})\n\n                    yield {\n                        \"type\": \"node_started\",\n                        \"started_node\": node_name,\n                        \"run_id\": run_id,\n                    }\n\n                    start_time = time.time()\n                    result = await self.executor.execute_node(\n                        session, node_name, user_input\n                    )\n                    elapsed_ms = (time.time() - start_time) * 1000\n\n                    result = self._apply_item_list_edits(\n                        node_name, result, item_list_values\n                    )\n                    session.results[node_name] = result\n                    node_results[node_name] = result\n                    node_statuses[node_name] = \"completed\"\n\n                    if can_persist:\n                        current_count = self.state.get_result_count(sheet_id, node_name)\n                        snapshot = {\n                            \"inputs\": input_values,\n                            \"selected_results\": selected_results,\n                        }\n                        self.state.save_result(sheet_id, node_name, result, snapshot)\n                        selected_results[node_name] = current_count\n\n                    graph_data = self._build_graph_data(\n                        node_results,\n                        node_statuses,\n                        input_values,\n                        {},\n                        sheet_id,\n                        selected_results,\n                    )\n                    graph_data[\"type\"] = \"node_complete\"\n                    graph_data[\"completed_node\"] = node_name\n                    graph_data[\"run_id\"] = run_id\n                    graph_data[\"execution_time_ms\"] = elapsed_ms\n                finally:\n                    await session.finish_node_execution(node_name)\n                yield graph_data\n\n        except Exception as e:\n            error_node = None\n            if nodes_to_execute:\n                current_idx = len(node_results)\n                if current_idx < len(nodes_to_execute):\n                    error_node = nodes_to_execute[current_idx]\n                    node_statuses[error_node] = \"error\"\n                    node_results[error_node] = {\"error\": str(e)}\n\n            graph_data = self._build_graph_data(\n                node_results,\n                node_statuses,\n                input_values,\n                {},\n                sheet_id,\n                selected_results,\n            )\n            graph_data[\"type\"] = \"error\"\n            graph_data[\"run_id\"] = run_id\n            graph_data[\"error\"] = str(e)\n            graph_data[\"nodes_to_clear\"] = nodes_to_execute\n            if error_node:\n                graph_data[\"node\"] = error_node\n                graph_data[\"completed_node\"] = error_node\n            yield graph_data\n\n    async def _execute_workflow_api(\n        self, request: Request, subgraph_id: str | None = None\n    ) -> JSONResponse:\n        try:\n            body = await request.json()\n        except Exception:\n            body = {}\n\n        input_values = body.get(\"inputs\", {})\n        session = ExecutionSession(self.graph)\n\n        subgraphs = self.graph.get_subgraphs()\n        output_node_names = set(self.graph.get_output_nodes())\n\n        if subgraph_id is None:\n            if len(subgraphs) > 1:\n                return JSONResponse(\n                    {\n                        \"error\": \"Multiple subgraphs detected. Please specify a subgraph_id.\",\n                        \"available_subgraphs\": [\n                            f\"subgraph_{i}\" for i in range(len(subgraphs))\n                        ],\n                    },\n                    status_code=400,\n                )\n            target_nodes = subgraphs[0] if subgraphs else set(self.graph.nodes.keys())\n        else:\n            if subgraph_id == \"main\" and len(subgraphs) == 1:\n                target_nodes = subgraphs[0]\n            elif subgraph_id.startswith(\"subgraph_\"):\n                try:\n                    idx = int(subgraph_id.split(\"_\")[1])\n                    if idx < 0 or idx >= len(subgraphs):\n                        return JSONResponse(\n                            {\"error\": f\"Subgraph '{subgraph_id}' not found\"},\n                            status_code=404,\n                        )\n                    target_nodes = subgraphs[idx]\n                except (ValueError, IndexError):\n                    return JSONResponse(\n                        {\"error\": f\"Invalid subgraph_id '{subgraph_id}'\"},\n                        status_code=400,\n                    )\n            else:\n                return JSONResponse(\n                    {\"error\": f\"Subgraph '{subgraph_id}' not found\"},\n                    status_code=404,\n                )\n\n        for node_name, node in self.graph.nodes.items():\n            if isinstance(node, ChoiceNode):\n                node_id = node_name.replace(\" \", \"_\").replace(\"-\", \"_\")\n                variant_idx = input_values.get(f\"{node_id}___selected_variant\", 0)\n                session.selected_variants[node_name] = variant_idx\n\n        execution_order = self.graph.get_execution_order()\n        nodes_to_execute = [n for n in execution_order if n in target_nodes]\n\n        entry_inputs: dict[str, dict[str, Any]] = {}\n        for node_name in nodes_to_execute:\n            node = self.graph.nodes[node_name]\n            if node._input_components:\n                node_inputs = {}\n                for port_name in node._input_components:\n                    input_node_id = f\"{node_name}__{port_name}\".replace(\n                        \" \", \"_\"\n                    ).replace(\"-\", \"_\")\n                    if input_node_id in input_values:\n                        node_inputs[port_name] = input_values[input_node_id]\n                if node_inputs:\n                    entry_inputs[node_name] = node_inputs\n\n        session.results = {}\n        node_results = {}\n\n        try:\n            for node_name in nodes_to_execute:\n                user_input = entry_inputs.get(node_name, {})\n                result = await self.executor.execute_node(\n                    session, node_name, user_input\n                )\n                node_results[node_name] = result\n        except Exception as e:\n            return JSONResponse(\n                {\"error\": f\"Execution error in node '{node_name}': {str(e)}\"},\n                status_code=500,\n            )\n\n        outputs = {}\n        for node_name in nodes_to_execute:\n            if node_name in output_node_names and node_name in node_results:\n                result = node_results[node_name]\n                result = self._transform_file_paths(result)\n                outputs[node_name] = result\n\n        return JSONResponse({\"outputs\": outputs})\n\n    def run(\n        self,\n        host: str | None = None,\n        port: int | None = None,\n        share: bool | None = None,\n        open_browser: bool = True,\n        **kwargs,\n    ):\n        from gradio.utils import colab_check, ipython_check\n\n        if host is None:\n            host = os.environ.get(\"GRADIO_SERVER_NAME\", \"127.0.0.1\")\n        if port is None:\n            port = int(os.environ.get(\"GRADIO_SERVER_PORT\", \"7860\"))\n\n        actual_port = _find_available_port(host, port)\n        if actual_port != port:\n            print(f\"\\n  Port {port} is in use, using {actual_port} instead.\")\n\n        self.graph._validate_edges()\n\n        is_colab = colab_check()\n        is_kaggle = os.environ.get(\"KAGGLE_KERNEL_RUN_TYPE\") is not None\n        is_notebook = is_colab or is_kaggle or ipython_check()\n\n        if share is None:\n            share = is_colab or is_kaggle\n\n        if is_notebook or share:\n            config = uvicorn.Config(\n                app=self.app,\n                host=host,\n                port=actual_port,\n                log_level=\"warning\",\n            )\n            server = _Server(config)\n            server.run_in_thread()\n\n            local_url = f\"http://{host}:{actual_port}\"\n            print(f\"\\n  UI running at: {local_url}\")\n            if self.api_server:\n                print(f\"  API server at: {local_url}/api\")\n\n            share_url = None\n            if share:\n                from gradio.networking import setup_tunnel\n\n                share_token = secrets.token_urlsafe(32)\n                share_url = setup_tunnel(\n                    local_host=host,\n                    local_port=actual_port,\n                    share_token=share_token,\n                    share_server_address=None,\n                    share_server_tls_certificate=None,\n                )\n                print(f\"  Public URL: {share_url}\")\n                print(\n                    \"\\n  This share link expires in 1 week. For permanent hosting, deploy to Hugging Face Spaces.\\n\"\n                )\n\n            if is_colab or is_kaggle:\n                from IPython.display import HTML, display\n\n                url = share_url or local_url\n                display(\n                    HTML(f'<a href=\"{url}\" target=\"_blank\">Open daggr app: {url}</a>')\n                )\n            elif open_browser:\n                webbrowser.open_new_tab(share_url or local_url)\n\n            try:\n                while True:\n                    time.sleep(1)\n            except KeyboardInterrupt:\n                print(\"\\nShutting down...\")\n                server.close()\n        else:\n            local_url = f\"http://{host}:{actual_port}\"\n            print(f\"\\n  UI running at: {local_url}\")\n            if self.api_server:\n                print(f\"  API server at: {local_url}/api\")\n            print()\n            if open_browser:\n                threading.Timer(0.5, lambda: webbrowser.open_new_tab(local_url)).start()\n            uvicorn.run(\n                self.app, host=host, port=actual_port, log_level=\"warning\", **kwargs\n            )\n\n\nclass _Server(uvicorn.Server):\n    def install_signal_handlers(self):\n        pass\n\n    def run_in_thread(self):\n        self.thread = threading.Thread(target=self.run, daemon=True)\n        self.thread.start()\n        start = time.time()\n        while not self.started:\n            time.sleep(1e-3)\n            if time.time() - start > 5:\n                raise RuntimeError(\n                    \"Server failed to start. Please check that the port is available.\"\n                )\n\n    def close(self):\n        self.should_exit = True\n        self.thread.join(timeout=5)\n"
  },
  {
    "path": "daggr/session.py",
    "content": "\"\"\"Session management for daggr, including per-session execution contexts for security isolation and concurrency management.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom typing import TYPE_CHECKING, Any\n\nif TYPE_CHECKING:\n    from daggr.graph import Graph\n\n\nclass ConcurrencyManager:\n    \"\"\"Manages concurrency limits for FnNode execution within a session.\n\n    By default, only one FnNode runs at a time per session. FnNodes can opt\n    into concurrent execution via the `concurrent` parameter, and can share\n    limits via `concurrency_group`.\n    \"\"\"\n\n    def __init__(self):\n        self._default_semaphore = asyncio.Semaphore(1)\n        self._group_semaphores: dict[str, asyncio.Semaphore] = {}\n        self._lock = asyncio.Lock()\n\n    async def get_semaphore(\n        self,\n        concurrent: bool,\n        concurrency_group: str | None,\n        max_concurrent: int,\n    ) -> asyncio.Semaphore | None:\n        \"\"\"Get the appropriate semaphore for a FnNode.\n\n        Returns None if the node should run without concurrency limits\n        (concurrent=True with no group).\n        \"\"\"\n        if not concurrent:\n            return self._default_semaphore\n\n        if concurrency_group:\n            async with self._lock:\n                if concurrency_group not in self._group_semaphores:\n                    self._group_semaphores[concurrency_group] = asyncio.Semaphore(\n                        max_concurrent\n                    )\n                return self._group_semaphores[concurrency_group]\n\n        return None\n\n\nclass ExecutionSession:\n    \"\"\"Per-session execution context.\n\n    Each WebSocket connection gets its own ExecutionSession, providing:\n    - Isolated HF token\n    - Isolated results cache\n    - Isolated Gradio client cache\n    - Per-session concurrency management\n    - Node execution coordination (wait for dependencies)\n    \"\"\"\n\n    def __init__(self, graph: Graph, hf_token: str | None = None):\n        self.graph = graph\n        self.hf_token = hf_token\n        self.results: dict[str, Any] = {}\n        self.scattered_results: dict[str, list[Any]] = {}\n        self.selected_variants: dict[str, int] = {}\n        self.clients: dict[str, Any] = {}\n        self.concurrency = ConcurrencyManager()\n\n        self._executing_nodes: dict[str, asyncio.Event] = {}\n        self._execution_lock = asyncio.Lock()\n\n    def set_hf_token(self, token: str | None):\n        \"\"\"Update the HF token and clear cached clients.\"\"\"\n        if token != self.hf_token:\n            self.hf_token = token\n            self.clients = {}\n\n    def clear_results(self):\n        \"\"\"Clear cached results for a fresh execution.\"\"\"\n        self.results = {}\n        self.scattered_results = {}\n\n    async def wait_for_node(self, node_name: str) -> bool:\n        \"\"\"Wait for a node to finish executing if it's currently running.\n\n        Returns True if we waited (node was executing), False otherwise.\n        \"\"\"\n        async with self._execution_lock:\n            event = self._executing_nodes.get(node_name)\n\n        if event:\n            await event.wait()\n            return True\n        return False\n\n    async def start_node_execution(self, node_name: str) -> bool:\n        \"\"\"Mark a node as starting execution.\n\n        Returns True if we can start (no one else is executing it).\n        Returns False if someone else is already executing it.\n        \"\"\"\n        async with self._execution_lock:\n            if node_name in self._executing_nodes:\n                return False\n            self._executing_nodes[node_name] = asyncio.Event()\n            return True\n\n    async def finish_node_execution(self, node_name: str):\n        \"\"\"Mark a node as finished executing and notify waiters.\"\"\"\n        async with self._execution_lock:\n            event = self._executing_nodes.pop(node_name, None)\n            if event:\n                event.set()\n"
  },
  {
    "path": "daggr/state.py",
    "content": "from __future__ import annotations\n\nimport json\nimport os\nimport sqlite3\nimport uuid\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any\n\nfrom huggingface_hub import constants\n\n\ndef get_daggr_cache_dir() -> Path:\n    \"\"\"Get the daggr cache directory, respecting HF_HOME env var.\"\"\"\n    cache_dir = Path(constants.HF_HOME) / \"daggr\"\n    cache_dir.mkdir(parents=True, exist_ok=True)\n    return cache_dir\n\n\ndef get_daggr_files_dir() -> Path:\n    files_dir = get_daggr_cache_dir() / \"files\"\n    files_dir.mkdir(parents=True, exist_ok=True)\n    return files_dir\n\n\nclass SessionState:\n    def __init__(self, db_path: str | None = None):\n        if db_path is None:\n            db_path = str(get_daggr_cache_dir() / \"sessions.db\")\n        self.db_path = db_path\n        self._init_db()\n\n    def _init_db(self):\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n\n        self._migrate_legacy_schema(cursor)\n\n        cursor.execute(\"\"\"\n            CREATE TABLE IF NOT EXISTS sheets (\n                sheet_id TEXT PRIMARY KEY,\n                user_id TEXT NOT NULL,\n                graph_name TEXT NOT NULL,\n                name TEXT,\n                transform TEXT,\n                created_at TEXT,\n                updated_at TEXT\n            )\n        \"\"\")\n\n        cursor.execute(\"PRAGMA table_info(sheets)\")\n        columns = [col[1] for col in cursor.fetchall()]\n        if \"transform\" not in columns:\n            cursor.execute(\"ALTER TABLE sheets ADD COLUMN transform TEXT\")\n\n        cursor.execute(\"\"\"\n            CREATE INDEX IF NOT EXISTS idx_sheets_user_graph \n            ON sheets(user_id, graph_name)\n        \"\"\")\n\n        cursor.execute(\"\"\"\n            CREATE TABLE IF NOT EXISTS node_inputs (\n                id INTEGER PRIMARY KEY AUTOINCREMENT,\n                sheet_id TEXT,\n                node_name TEXT,\n                port_name TEXT,\n                value TEXT,\n                updated_at TEXT,\n                FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE,\n                UNIQUE(sheet_id, node_name, port_name)\n            )\n        \"\"\")\n\n        cursor.execute(\"\"\"\n            CREATE INDEX IF NOT EXISTS idx_node_inputs_sheet \n            ON node_inputs(sheet_id)\n        \"\"\")\n\n        cursor.execute(\"\"\"\n            CREATE TABLE IF NOT EXISTS node_results (\n                id INTEGER PRIMARY KEY AUTOINCREMENT,\n                sheet_id TEXT,\n                node_name TEXT,\n                result TEXT,\n                inputs_snapshot TEXT,\n                created_at TEXT,\n                FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE\n            )\n        \"\"\")\n\n        cursor.execute(\"PRAGMA table_info(node_results)\")\n        result_columns = [col[1] for col in cursor.fetchall()]\n        if \"inputs_snapshot\" not in result_columns:\n            cursor.execute(\"ALTER TABLE node_results ADD COLUMN inputs_snapshot TEXT\")\n\n        cursor.execute(\"\"\"\n            CREATE INDEX IF NOT EXISTS idx_node_results_sheet_node \n            ON node_results(sheet_id, node_name)\n        \"\"\")\n\n        conn.commit()\n        conn.close()\n\n    def _migrate_legacy_schema(self, cursor):\n        cursor.execute(\n            \"SELECT name FROM sqlite_master WHERE type='table' AND name='node_inputs'\"\n        )\n        if cursor.fetchone():\n            cursor.execute(\"PRAGMA table_info(node_inputs)\")\n            columns = [col[1] for col in cursor.fetchall()]\n            if \"session_id\" in columns and \"sheet_id\" not in columns:\n                cursor.execute(\"ALTER TABLE node_inputs RENAME TO _node_inputs_old\")\n                cursor.execute(\"ALTER TABLE node_results RENAME TO _node_results_old\")\n                cursor.execute(\"ALTER TABLE sessions RENAME TO _sessions_old\")\n\n                cursor.execute(\"\"\"\n                    CREATE TABLE sheets (\n                        sheet_id TEXT PRIMARY KEY,\n                        user_id TEXT NOT NULL,\n                        graph_name TEXT NOT NULL,\n                        name TEXT,\n                        created_at TEXT,\n                        updated_at TEXT\n                    )\n                \"\"\")\n                cursor.execute(\"\"\"\n                    CREATE TABLE node_inputs (\n                        id INTEGER PRIMARY KEY AUTOINCREMENT,\n                        sheet_id TEXT,\n                        node_name TEXT,\n                        port_name TEXT,\n                        value TEXT,\n                        updated_at TEXT,\n                        FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE,\n                        UNIQUE(sheet_id, node_name, port_name)\n                    )\n                \"\"\")\n                cursor.execute(\"\"\"\n                    CREATE TABLE node_results (\n                        id INTEGER PRIMARY KEY AUTOINCREMENT,\n                        sheet_id TEXT,\n                        node_name TEXT,\n                        result TEXT,\n                        created_at TEXT,\n                        FOREIGN KEY (sheet_id) REFERENCES sheets(sheet_id) ON DELETE CASCADE\n                    )\n                \"\"\")\n\n                cursor.execute(\"\"\"\n                    INSERT INTO sheets (sheet_id, user_id, graph_name, name, created_at, updated_at)\n                    SELECT session_id, 'local', graph_name, 'Migrated Sheet', created_at, updated_at\n                    FROM _sessions_old\n                \"\"\")\n                cursor.execute(\"\"\"\n                    INSERT INTO node_inputs (sheet_id, node_name, port_name, value, updated_at)\n                    SELECT session_id, node_name, port_name, value, updated_at\n                    FROM _node_inputs_old\n                \"\"\")\n                cursor.execute(\"\"\"\n                    INSERT INTO node_results (sheet_id, node_name, result, created_at)\n                    SELECT session_id, node_name, result, created_at\n                    FROM _node_results_old\n                \"\"\")\n\n                cursor.execute(\"DROP TABLE _sessions_old\")\n                cursor.execute(\"DROP TABLE _node_inputs_old\")\n                cursor.execute(\"DROP TABLE _node_results_old\")\n\n    def get_effective_user_id(self, hf_user: dict | None = None) -> str | None:\n        is_on_spaces = os.environ.get(\"SPACE_ID\") is not None\n        if hf_user and hf_user.get(\"username\"):\n            return hf_user[\"username\"]\n        if is_on_spaces:\n            return None\n        return \"local\"\n\n    def create_sheet(\n        self, user_id: str, graph_name: str, name: str | None = None\n    ) -> str:\n        sheet_id = str(uuid.uuid4())\n        now = datetime.now().isoformat()\n\n        if not name:\n            count = self.get_sheet_count(user_id, graph_name)\n            name = f\"Sheet {count + 1}\"\n\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"INSERT INTO sheets (sheet_id, user_id, graph_name, name, created_at, updated_at) \n               VALUES (?, ?, ?, ?, ?, ?)\"\"\",\n            (sheet_id, user_id, graph_name, name, now, now),\n        )\n        conn.commit()\n        conn.close()\n        return sheet_id\n\n    def get_sheet_count(self, user_id: str, graph_name: str) -> int:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"SELECT COUNT(*) FROM sheets WHERE user_id = ? AND graph_name = ?\",\n            (user_id, graph_name),\n        )\n        count = cursor.fetchone()[0]\n        conn.close()\n        return count\n\n    def list_sheets(self, user_id: str, graph_name: str) -> list[dict[str, Any]]:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"SELECT sheet_id, name, created_at, updated_at \n               FROM sheets \n               WHERE user_id = ? AND graph_name = ?\n               ORDER BY updated_at DESC\"\"\",\n            (user_id, graph_name),\n        )\n        rows = cursor.fetchall()\n        conn.close()\n        return [\n            {\n                \"sheet_id\": row[0],\n                \"name\": row[1],\n                \"created_at\": row[2],\n                \"updated_at\": row[3],\n            }\n            for row in rows\n        ]\n\n    def get_sheet(self, sheet_id: str) -> dict[str, Any] | None:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"SELECT sheet_id, user_id, graph_name, name, transform, created_at, updated_at \n               FROM sheets WHERE sheet_id = ?\"\"\",\n            (sheet_id,),\n        )\n        row = cursor.fetchone()\n        conn.close()\n        if row:\n            transform = None\n            if row[4]:\n                try:\n                    transform = json.loads(row[4])\n                except (json.JSONDecodeError, TypeError):\n                    pass\n            return {\n                \"sheet_id\": row[0],\n                \"user_id\": row[1],\n                \"graph_name\": row[2],\n                \"name\": row[3],\n                \"transform\": transform,\n                \"created_at\": row[5],\n                \"updated_at\": row[6],\n            }\n        return None\n\n    def save_transform(self, sheet_id: str, x: float, y: float, scale: float) -> bool:\n        now = datetime.now().isoformat()\n        transform = json.dumps({\"x\": x, \"y\": y, \"scale\": scale})\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"UPDATE sheets SET transform = ?, updated_at = ? WHERE sheet_id = ?\",\n            (transform, now, sheet_id),\n        )\n        updated = cursor.rowcount > 0\n        conn.commit()\n        conn.close()\n        return updated\n\n    def rename_sheet(self, sheet_id: str, new_name: str) -> bool:\n        now = datetime.now().isoformat()\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"UPDATE sheets SET name = ?, updated_at = ? WHERE sheet_id = ?\",\n            (new_name, now, sheet_id),\n        )\n        updated = cursor.rowcount > 0\n        conn.commit()\n        conn.close()\n        return updated\n\n    def delete_sheet(self, sheet_id: str) -> bool:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\"DELETE FROM node_inputs WHERE sheet_id = ?\", (sheet_id,))\n        cursor.execute(\"DELETE FROM node_results WHERE sheet_id = ?\", (sheet_id,))\n        cursor.execute(\"DELETE FROM sheets WHERE sheet_id = ?\", (sheet_id,))\n        deleted = cursor.rowcount > 0\n        conn.commit()\n        conn.close()\n        return deleted\n\n    def get_or_create_sheet(\n        self, user_id: str, graph_name: str, sheet_id: str | None = None\n    ) -> str:\n        if sheet_id:\n            sheet = self.get_sheet(sheet_id)\n            if sheet and sheet[\"user_id\"] == user_id:\n                return sheet_id\n\n        sheets = self.list_sheets(user_id, graph_name)\n        if sheets:\n            return sheets[0][\"sheet_id\"]\n\n        return self.create_sheet(user_id, graph_name)\n\n    def save_input(self, sheet_id: str, node_name: str, port_name: str, value: Any):\n        now = datetime.now().isoformat()\n        value_json = json.dumps(value, default=str)\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"INSERT INTO node_inputs (sheet_id, node_name, port_name, value, updated_at)\n               VALUES (?, ?, ?, ?, ?)\n               ON CONFLICT(sheet_id, node_name, port_name) \n               DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at\"\"\",\n            (sheet_id, node_name, port_name, value_json, now),\n        )\n        cursor.execute(\n            \"UPDATE sheets SET updated_at = ? WHERE sheet_id = ?\",\n            (now, sheet_id),\n        )\n        conn.commit()\n        conn.close()\n\n    def get_inputs(self, sheet_id: str) -> dict[str, dict[str, Any]]:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"SELECT node_name, port_name, value FROM node_inputs WHERE sheet_id = ?\",\n            (sheet_id,),\n        )\n        results = cursor.fetchall()\n        conn.close()\n        inputs: dict[str, dict[str, Any]] = {}\n        for node_name, port_name, value_json in results:\n            if node_name not in inputs:\n                inputs[node_name] = {}\n            inputs[node_name][port_name] = json.loads(value_json)\n        return inputs\n\n    def save_result(\n        self,\n        sheet_id: str,\n        node_name: str,\n        result: Any,\n        inputs_snapshot: dict[str, Any] | None = None,\n    ):\n        now = datetime.now().isoformat()\n        result_json = json.dumps(result, default=str)\n        inputs_json = (\n            json.dumps(inputs_snapshot, default=str) if inputs_snapshot else None\n        )\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"INSERT INTO node_results (sheet_id, node_name, result, inputs_snapshot, created_at) VALUES (?, ?, ?, ?, ?)\",\n            (sheet_id, node_name, result_json, inputs_json, now),\n        )\n        cursor.execute(\n            \"UPDATE sheets SET updated_at = ? WHERE sheet_id = ?\",\n            (now, sheet_id),\n        )\n        conn.commit()\n        conn.close()\n\n    def get_latest_result(self, sheet_id: str, node_name: str) -> Any | None:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"SELECT result FROM node_results \n               WHERE sheet_id = ? AND node_name = ? \n               ORDER BY created_at DESC LIMIT 1\"\"\",\n            (sheet_id, node_name),\n        )\n        result = cursor.fetchone()\n        conn.close()\n        if result:\n            return json.loads(result[0])\n        return None\n\n    def get_result_count(self, sheet_id: str, node_name: str) -> int:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"SELECT COUNT(*) FROM node_results WHERE sheet_id = ? AND node_name = ?\",\n            (sheet_id, node_name),\n        )\n        count = cursor.fetchone()[0]\n        conn.close()\n        return count\n\n    def get_result_by_index(\n        self, sheet_id: str, node_name: str, index: int\n    ) -> Any | None:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"SELECT result FROM node_results \n               WHERE sheet_id = ? AND node_name = ? \n               ORDER BY created_at ASC\"\"\",\n            (sheet_id, node_name),\n        )\n        results = cursor.fetchall()\n        conn.close()\n        if results and 0 <= index < len(results):\n            return json.loads(results[index][0])\n        elif results:\n            return json.loads(results[-1][0])\n        return None\n\n    def get_all_results(self, sheet_id: str) -> dict[str, list[Any]]:\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\n            \"\"\"SELECT node_name, result, inputs_snapshot FROM node_results \n               WHERE sheet_id = ? \n               ORDER BY created_at ASC\"\"\",\n            (sheet_id,),\n        )\n        results = cursor.fetchall()\n        conn.close()\n        all_results: dict[str, list[Any]] = {}\n        for node_name, result_json, inputs_json in results:\n            if node_name not in all_results:\n                all_results[node_name] = []\n            result_data = {\n                \"result\": json.loads(result_json),\n                \"inputs_snapshot\": json.loads(inputs_json) if inputs_json else None,\n            }\n            all_results[node_name].append(result_data)\n        return all_results\n\n    def get_sheet_state(self, sheet_id: str) -> dict[str, Any]:\n        return {\n            \"inputs\": self.get_inputs(sheet_id),\n            \"results\": self.get_all_results(sheet_id),\n        }\n\n    def clear_sheet_data(self, sheet_id: str):\n        conn = sqlite3.connect(self.db_path)\n        cursor = conn.cursor()\n        cursor.execute(\"DELETE FROM node_inputs WHERE sheet_id = ?\", (sheet_id,))\n        cursor.execute(\"DELETE FROM node_results WHERE sheet_id = ?\", (sheet_id,))\n        conn.commit()\n        conn.close()\n\n    def create_session(self, graph_name: str) -> str:\n        return self.create_sheet(\"local\", graph_name)\n\n    def get_or_create_session(self, session_id: str | None, graph_name: str) -> str:\n        return self.get_or_create_sheet(\"local\", graph_name, session_id)\n"
  },
  {
    "path": "examples/01_quickstart.py",
    "content": "# Showcases basic GradioNode chaining: generate an image then remove its background.\nimport random\n\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\nglm_image = GradioNode(\n    \"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": gr.Textbox(  # An input node is created for the prompt\n            label=\"Prompt\",\n            value=\"A cheetah in the grassy savanna.\",\n            lines=3,\n        ),\n        \"height\": 1024,  # Fixed value (does not appear in the canvas)\n        \"width\": 1024,  # Fixed value (does not appear in the canvas)\n        \"seed\": random.random,  # Functions are rerun every time the workflow is run (not shown in the canvas)\n    },\n    outputs={\n        \"image\": gr.Image(\n            label=\"Image\"  # Display original image\n        ),\n    },\n)\n\nbackground_remover = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/image\",\n    inputs={\n        \"image\": glm_image.image,\n    },\n    postprocess=lambda _, final: final,\n    outputs={\n        \"image\": gr.Image(label=\"Final Image\"),  # Display only final image\n    },\n)\n\ngraph = Graph(\n    name=\"Transparent Background Image Generator\", nodes=[glm_image, background_remover]\n)\n\ngraph.launch()\n"
  },
  {
    "path": "examples/02_voice_design_comparator_app.py",
    "content": "# Showcases parallel execution by comparing two TTS services (Qwen and Maya) with the same input.\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\nvoice_description = gr.Textbox(\n    label=\"Host Voice Description\",\n    value=\"Deep British voice that is very professional and authoritative...\",\n    lines=3,\n)\n\ntext_to_speak = gr.Textbox(\n    label=\"Text to Speak\",\n    value=\"Hi! I'm the host of a podcast. It's going to be a great episode!\",\n    lines=3,\n)\n\nqwen_voice = GradioNode(\n    space_or_url=\"Qwen/Qwen3-TTS\",\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": voice_description,\n        \"language\": \"Auto\",\n        \"text\": text_to_speak,\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Host Voice\"),\n        \"status\": None,\n    },\n)\n\nmaya_voice = GradioNode(\n    space_or_url=\"maya-research/maya1\",\n    api_name=\"/generate_speech\",\n    inputs={\n        \"preset_name\": \"Male American\",\n        \"description\": voice_description,\n        \"text\": text_to_speak,\n        \"temperature\": 0.4,\n        \"max_tokens\": 1500,\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Host Voice\"),\n        \"status\": None,\n    },\n)\n\ngraph = Graph(\n    name=\"Voice Designing Comparator\",\n    nodes=[qwen_voice, maya_voice],\n)\n\ngraph.launch()\n"
  },
  {
    "path": "examples/03_mock_podcast_app.py",
    "content": "# Showcases scatter/gather with ItemList: generate dialogue items and process each with TTS, then combine.\nimport ssl\nimport tempfile\nimport time\nimport urllib.request\n\nimport gradio as gr\nfrom pydub import AudioSegment\n\nfrom daggr import FnNode, GradioNode, Graph, ItemList\n\nhost_voice = GradioNode(\n    space_or_url=\"abidlabs/tts\",  # Currently mocked. But this would be a call to e.g. Qwen/Qwen3-TTS\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(\n            label=\"Host Voice Description\",\n            value=\"Deep British voice that is very professional and authoritative...\",\n            lines=3,\n        ),\n        \"language\": \"Auto\",\n        \"text\": \"Hi! I'm the host of podcast. It's going to be a great episode!\",\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Host Voice\"),\n        \"status\": gr.Text(visible=False),\n    },\n)\n\n\nguest_voice = GradioNode(\n    space_or_url=\"abidlabs/tts\",\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(\n            label=\"Guest Voice Description\",\n            value=\"Energetic, friendly young voice with American accent...\",\n            lines=3,\n        ),\n        \"language\": \"Auto\",\n        \"text\": \"Hi! I'm the guest of podcast. Super excited to be here!\",\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Guest Voice\"),\n        \"status\": gr.Text(visible=False),\n    },\n)\n\n\ndef generate_dialogue(topic: str) -> list:\n    time.sleep(1)\n    return [\n        {\"speaker\": \"Host\", \"text\": \"Hello, welcome to the show!\"},\n        {\"speaker\": \"Guest\", \"text\": \"Thanks for having me!\"},\n        {\"speaker\": \"Host\", \"text\": \"Today we're discussing \" + topic},\n        {\"speaker\": \"Guest\", \"text\": \"Yes, it's a fascinating topic!\"},\n    ]\n\n\ndialogue = FnNode(\n    fn=generate_dialogue,\n    inputs={\n        \"topic\": gr.Textbox(label=\"Topic\", value=\"AI in healthcare...\"),\n    },\n    outputs={\n        \"items\": ItemList(\n            speaker=gr.Dropdown(choices=[\"Host\", \"Guest\"]),\n            text=gr.Textbox(lines=2),\n        ),\n    },\n)\n\n\ndef chatterbox(text: str, speaker: str, host_audio: str, guest_audio: str) -> str:\n    voice_map = {\"Host\": host_audio, \"Guest\": guest_audio}\n    return voice_map.get(speaker, host_audio)\n\n\nsamples = FnNode(\n    fn=chatterbox,\n    inputs={\n        \"text\": dialogue.items.text,\n        \"speaker\": dialogue.items.speaker,\n        \"host_audio\": host_voice.audio,\n        \"guest_audio\": guest_voice.audio,\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Sample\"),\n    },\n)\n\n\ndef combine_audio_files(audio_files: list[str]) -> str:\n    if not audio_files:\n        return None\n    if len(audio_files) == 1:\n        return audio_files[0]\n\n    combined = AudioSegment.empty()\n    for audio_path in audio_files:\n        if audio_path:\n            if audio_path.startswith((\"http://\", \"https://\")):\n                tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".mp3\")\n                ctx = ssl.create_default_context()\n                ctx.check_hostname = False\n                ctx.verify_mode = ssl.CERT_NONE\n                with urllib.request.urlopen(audio_path, context=ctx) as response:\n                    tmp.write(response.read())\n                tmp.close()\n                segment = AudioSegment.from_file(tmp.name)\n            else:\n                segment = AudioSegment.from_file(audio_path)\n            combined += segment\n\n    output_path = tempfile.mktemp(suffix=\".mp3\")\n    combined.export(output_path, format=\"mp3\")\n    return output_path\n\n\nfull_audio = FnNode(\n    fn=combine_audio_files,\n    inputs={\n        \"audio_files\": samples.audio.all(),\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Full Audio\"),\n    },\n)\n\ngraph = Graph(\n    name=\"Mock Podcast Generator\",\n    nodes=[host_voice, guest_voice, dialogue, samples, full_audio],\n)\n\ngraph.launch()\n"
  },
  {
    "path": "examples/04_complete_podcast_app.py",
    "content": "# Showcases a complete podcast generator using real TTS (Qwen3-TTS) with scatter/gather for multi-speaker audio.\nimport ssl\nimport tempfile\nimport time\nimport urllib.request\n\nimport gradio as gr\nfrom pydub import AudioSegment\n\nfrom daggr import FnNode, GradioNode, Graph, ItemList\n\nhost_voice = GradioNode(\n    space_or_url=\"Qwen/Qwen3-TTS\",  # Currently mocked. But this would be a call to e.g. Qwen/Qwen3-TTS\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(\n            label=\"Host Voice Description\",\n            value=\"Deep British voice that is very professional and authoritative...\",\n            lines=3,\n        ),\n        \"language\": \"English\",\n        \"text\": \"Hi! I'm the host of this podcast. It's going to be a great episode!\",\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Host Voice\"),\n        \"status\": None,\n    },\n)\n\n\nguest_voice = GradioNode(\n    space_or_url=\"Qwen/Qwen3-TTS\",  # Currently mocked. But this would be a call to e.g. Qwen/Qwen3-TTS\n    api_name=\"/generate_voice_design\",\n    inputs={\n        \"voice_description\": gr.Textbox(\n            label=\"Guest Voice Description\",\n            value=\"Energetic, friendly young woman with American accent...\",\n            lines=3,\n        ),\n        \"language\": \"English\",\n        \"text\": \"Hi! I'm the guest on this podcast. Super excited to be here!\",\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Guest Voice\"),\n        \"status\": None,\n    },\n)\n\n\ndef generate_dialogue(topic: str) -> list:\n    time.sleep(1)\n    return [\n        {\"speaker\": \"Host\", \"text\": \"Hello, welcome to the show!\"},\n        {\"speaker\": \"Guest\", \"text\": \"Thanks for having me!\"},\n        {\"speaker\": \"Host\", \"text\": \"Today we're discussing \" + topic},\n        {\"speaker\": \"Guest\", \"text\": \"Yes, it's a fascinating topic!\"},\n    ]\n\n\ndialogue = FnNode(\n    fn=generate_dialogue,\n    inputs={\n        \"topic\": gr.Textbox(label=\"Topic\", value=\"AI in healthcare...\"),\n    },\n    outputs={\n        \"items\": ItemList(\n            speaker=gr.Dropdown(choices=[\"Host\", \"Guest\"]),\n            text=gr.Textbox(lines=2),\n        ),\n    },\n)\n\n\ndef chatterbox(text: str, speaker: str, host_audio: str, guest_audio: str) -> str:\n    voice_map = {\"Host\": host_audio, \"Guest\": guest_audio}\n    return voice_map.get(speaker, host_audio)\n\n\nsamples = FnNode(\n    fn=chatterbox,\n    inputs={\n        \"text\": dialogue.items.text,\n        \"speaker\": dialogue.items.speaker,\n        \"host_audio\": host_voice.audio,\n        \"guest_audio\": guest_voice.audio,\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Sample\"),\n    },\n)\n\n\ndef combine_audio_files(audio_files: list[str]) -> str:\n    if not audio_files:\n        return None\n    if len(audio_files) == 1:\n        return audio_files[0]\n\n    combined = AudioSegment.empty()\n    for audio_path in audio_files:\n        if audio_path:\n            if audio_path.startswith((\"http://\", \"https://\")):\n                tmp = tempfile.NamedTemporaryFile(delete=False, suffix=\".mp3\")\n                ctx = ssl.create_default_context()\n                ctx.check_hostname = False\n                ctx.verify_mode = ssl.CERT_NONE\n                with urllib.request.urlopen(audio_path, context=ctx) as response:\n                    tmp.write(response.read())\n                tmp.close()\n                segment = AudioSegment.from_file(tmp.name)\n            else:\n                segment = AudioSegment.from_file(audio_path)\n            combined += segment\n\n    output_path = tempfile.mktemp(suffix=\".mp3\")\n    combined.export(output_path, format=\"mp3\")\n    return output_path\n\n\nfull_audio = FnNode(\n    fn=combine_audio_files,\n    inputs={\n        \"audio_files\": samples.audio.all(),\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Full Audio\"),\n    },\n)\n\ngraph = Graph(\n    name=\"Complete Podcast Generator\",\n    nodes=[host_voice, guest_voice, dialogue, samples, full_audio],\n)\n\ngraph.launch()\n"
  },
  {
    "path": "examples/05_local_translation_app.py",
    "content": "# Showcases running a GradioNode locally with run_locally=True instead of calling a remote Space.\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\ntranslator = GradioNode(\n    \"abidlabs/en2fr\",\n    api_name=\"/predict\",\n    run_locally=True,\n    inputs={\n        \"text\": gr.Textbox(\n            label=\"English Text\",\n            value=\"Hello, how are you today?\",\n            lines=3,\n        ),\n    },\n    outputs={\n        \"translation\": gr.Textbox(label=\"French Translation\"),\n    },\n)\n\ngraph = Graph(name=\"English to French Translator (Local)\", nodes=[translator])\n\ngraph.launch()\n"
  },
  {
    "path": "examples/06_pig_latin_voice_app.py",
    "content": "# Showcases InferenceNode for speech-to-text and text-to-speech with an FnNode transformation in between.\nimport gradio as gr\n\nfrom daggr import FnNode, Graph, InferenceNode\n\noriginal = InferenceNode(\n    model=\"openai/whisper-large-v3:replicate\",\n    inputs={\n        \"audio\": gr.Audio(label=\"Audio\"),\n    },\n    outputs={\n        \"text\": gr.Textbox(label=\"Text\"),\n    },\n)\n\n\ndef pig_latin_sentence(text: str) -> str:\n    words = text.split()\n    pig_latin_words = []\n    for word in words:\n        pig_latin_words.append(word[1:] + word[0] + \"ay\")\n    return \" \".join(pig_latin_words)\n\n\npig_latin = FnNode(\n    fn=pig_latin_sentence,\n    inputs={\n        \"text\": original.text,\n    },\n    outputs={\n        \"text\": gr.Textbox(label=\"Text\"),\n    },\n)\n\noutput = InferenceNode(\n    model=\"hexgrad/Kokoro-82M\",\n    inputs={\n        \"text\": pig_latin.text,\n    },\n    outputs={\n        \"audio\": gr.Audio(label=\"Audio\"),\n    },\n)\n\ngraph = Graph(name=\"Pig Latin Voice App\", nodes=[original, pig_latin, output])\n\ngraph.launch()\n"
  },
  {
    "path": "examples/07_image_to_3d_app.py",
    "content": "# Showcases a multi-step image-to-3D pipeline: background removal → downscaling → FLUX enhancement → TRELLIS 3D.\nimport uuid\nfrom typing import Any\n\nimport gradio as gr\nfrom PIL import Image\n\nfrom daggr import FnNode, GradioNode, Graph, InferenceNode\nfrom daggr.state import get_daggr_files_dir\n\n\ndef downscale_image_to_file(image: Any, scale: float = 0.25) -> str | None:\n    pil_img = Image.open(image)\n    scale_f = max(0.05, min(1.0, float(scale)))\n    w, h = pil_img.size\n    new_w = max(1, int(w * scale_f))\n    new_h = max(1, int(h * scale_f))\n    resized = pil_img.resize((new_w, new_h), resample=Image.LANCZOS)\n    out_path = get_daggr_files_dir() / f\"{uuid.uuid4()}.png\"\n    resized.save(out_path)\n    return str(out_path)\n\n\nbackground_remover = GradioNode(\n    \"merve/background-removal\",\n    api_name=\"/image\",\n    run_locally=True,\n    inputs={\n        \"image\": gr.Image(),\n    },\n    outputs={\n        \"original_image\": None,\n        \"final_image\": gr.Image(label=\"Final Image\"),\n    },\n)\n\ndownscaler = FnNode(\n    downscale_image_to_file,\n    name=\"Downscale image for Inference\",\n    inputs={\n        \"image\": background_remover.final_image,\n        \"scale\": gr.Slider(\n            label=\"Downscale factor\",\n            minimum=0.25,\n            maximum=0.75,\n            step=0.05,\n            value=0.25,\n        ),\n    },\n    outputs={\n        \"image\": gr.Image(label=\"Downscaled Image\", type=\"filepath\"),\n    },\n)\n\nflux_enhancer = InferenceNode(\n    model=\"black-forest-labs/FLUX.2-klein-4B:fal-ai\",\n    inputs={\n        \"image\": downscaler.image,\n        \"prompt\": gr.Textbox(\n            label=\"prompt\",\n            value=(\"Transform this into a clean 3D asset render\"),\n            lines=3,\n        ),\n    },\n    outputs={\n        \"image\": gr.Image(label=\"3D-Ready Enhanced Image\"),\n    },\n)\n\n\ntrellis_3d = GradioNode(\n    \"microsoft/TRELLIS.2\",\n    api_name=\"/image_to_3d\",\n    inputs={\n        \"image\": flux_enhancer.image,\n        \"ss_guidance_strength\": 7.5,\n        \"ss_sampling_steps\": 12,\n    },\n    outputs={\n        \"glb\": gr.HTML(label=\"3D Asset (GLB preview)\"),\n    },\n)\n\ngraph = Graph(\n    name=\"Image to 3D Asset Pipeline\",\n    nodes=[background_remover, downscaler, flux_enhancer, trellis_3d],\n)\n\n\nif __name__ == \"__main__\":\n    graph.launch()\n"
  },
  {
    "path": "examples/08_text_to_3d_app.py",
    "content": "# Showcases a text-to-3D pipeline: FLUX image generation → background removal → TRELLIS mesh extraction.\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\ntext_to_image = GradioNode(\n    \"hysts-mcp/FLUX.1-dev\",\n    api_name=\"/infer\",\n    inputs={\n        \"prompt\": gr.Textbox(\n            label=\"Prompt\",\n            value=\"A cute baby dragon breathing fire\",\n            lines=3,\n        ),\n        \"height\": 1024,\n        \"width\": 1024,\n        \"seed\": gr.Number(\n            label=\"Seed (Image generation)\", value=0, minimum=0, maximum=1000\n        ),\n    },\n    outputs={\n        \"image\": gr.Image(label=\"Image\"),\n    },\n)\n\nbackground_remover = GradioNode(\n    \"hysts-mcp/rembg\",\n    api_name=\"/remove_background\",\n    inputs={\n        \"image\": text_to_image.image,\n    },\n    outputs={\n        \"output\": gr.Image(label=\"Output\"),\n        \"original_image\": None,\n    },\n)\n\nimage_to_3d_step1 = GradioNode(\n    \"hysts-mcp/TRELLIS\",\n    api_name=\"/image_to_3d\",\n    inputs={\n        \"image\": background_remover.output,\n        \"seed\": gr.Number(\n            label=\"Seed (Mesh generation)\", value=0, minimum=0, maximum=1000\n        ),\n        \"ss_guidance_strength\": 7.5,\n        \"ss_sampling_steps\": 12,\n        \"slat_guidance_strength\": 3.0,\n        \"slat_sampling_steps\": 12,\n    },\n    outputs={\n        \"state\": gr.File(label=\"State file\"),\n        \"video\": gr.Video(label=\"Video visualization\"),\n    },\n)\n\nimage_to_3d_step2 = GradioNode(\n    \"hysts-mcp/TRELLIS\",\n    api_name=\"/extract_glb\",\n    inputs={\n        \"state_path\": image_to_3d_step1.state,\n        \"mesh_simplify\": 0.95,\n        \"texture_size\": 1024,\n    },\n    outputs={\n        \"Mesh\": gr.Model3D(label=\"Mesh\"),\n    },\n)\n\ngraph = Graph(\n    name=\"text to image to 3d\",\n    nodes=[text_to_image, background_remover, image_to_3d_step1, image_to_3d_step2],\n)\n\ngraph.launch()\n"
  },
  {
    "path": "examples/09_slideshow_app.py",
    "content": "# Showcases parallel image generation, video transitions between scenes, and ffmpeg concatenation into a slideshow.\nimport subprocess\nimport tempfile\n\nimport gradio as gr\nfrom PIL import Image\n\nfrom daggr import FnNode, GradioNode, Graph\n\n\ndef resize_image(image_path: str, size: int = 256) -> str:\n    \"\"\"Resize and center-crop image to square dimensions (required by video API).\"\"\"\n    img = Image.open(image_path)\n    # Center crop to square\n    w, h = img.size\n    min_dim = min(w, h)\n    left = (w - min_dim) // 2\n    top = (h - min_dim) // 2\n    img = img.crop((left, top, left + min_dim, top + min_dim))\n    # Resize to target size\n    img = img.resize((size, size), Image.Resampling.LANCZOS)\n    output_path = tempfile.mktemp(suffix=\".png\")\n    img.save(output_path, \"PNG\")\n    return output_path\n\n\nprompt1 = gr.Textbox(\n    label=\"Scene 1\",\n    value=\"A serene mountain landscape at sunrise, golden light rays, photorealistic\",\n    lines=2,\n)\n\nprompt2 = gr.Textbox(\n    label=\"Scene 2\",\n    value=\"A dense forest with mist and sunbeams filtering through trees, photorealistic\",\n    lines=2,\n)\n\nprompt3 = gr.Textbox(\n    label=\"Scene 3\",\n    value=\"An ocean wave crashing on rocks at sunset, photorealistic\",\n    lines=2,\n)\n\nprompt4 = gr.Textbox(\n    label=\"Scene 4\",\n    value=\"A starry night sky with the milky way over a desert, photorealistic\",\n    lines=2,\n)\n\nprompt5 = gr.Textbox(\n    label=\"Scene 5\",\n    value=\"Northern lights dancing over a frozen lake, photorealistic\",\n    lines=2,\n)\n\ntransition_prompt = gr.Textbox(\n    label=\"Transition Style\",\n    value=\"Smooth cinematic morph transition, natural movement\",\n    lines=1,\n)\n\nimage1 = GradioNode(\n    space_or_url=\"Tongyi-MAI/Z-Image-Turbo\",\n    api_name=\"/generate\",\n    name=\"Image 1\",\n    inputs={\n        \"prompt\": prompt1,\n        \"resolution\": \"1280x720 ( 16:9 )\",\n        \"steps\": 8,\n        \"random_seed\": True,\n    },\n    postprocess=lambda images, seed_used, seed_number: images[0][\"image\"],\n    outputs={\n        \"image\": gr.Image(label=\"Scene 1\"),\n    },\n)\n\nimage2 = GradioNode(\n    space_or_url=\"Tongyi-MAI/Z-Image-Turbo\",\n    api_name=\"/generate\",\n    name=\"Image 2\",\n    inputs={\n        \"prompt\": prompt2,\n        \"resolution\": \"1280x720 ( 16:9 )\",\n        \"steps\": 8,\n        \"random_seed\": True,\n    },\n    postprocess=lambda images, seed_used, seed_number: images[0][\"image\"],\n    outputs={\n        \"image\": gr.Image(label=\"Scene 2\"),\n    },\n)\n\nimage3 = GradioNode(\n    space_or_url=\"Tongyi-MAI/Z-Image-Turbo\",\n    api_name=\"/generate\",\n    name=\"Image 3\",\n    inputs={\n        \"prompt\": prompt3,\n        \"resolution\": \"1280x720 ( 16:9 )\",\n        \"steps\": 8,\n        \"random_seed\": True,\n    },\n    postprocess=lambda images, seed_used, seed_number: images[0][\"image\"],\n    outputs={\n        \"image\": gr.Image(label=\"Scene 3\"),\n    },\n)\n\nimage4 = GradioNode(\n    space_or_url=\"Tongyi-MAI/Z-Image-Turbo\",\n    api_name=\"/generate\",\n    name=\"Image 4\",\n    inputs={\n        \"prompt\": prompt4,\n        \"resolution\": \"1280x720 ( 16:9 )\",\n        \"steps\": 8,\n        \"random_seed\": True,\n    },\n    postprocess=lambda images, seed_used, seed_number: images[0][\"image\"],\n    outputs={\n        \"image\": gr.Image(label=\"Scene 3\"),\n    },\n)\n\nimage5 = GradioNode(\n    space_or_url=\"Tongyi-MAI/Z-Image-Turbo\",\n    api_name=\"/generate\",\n    name=\"Image 5\",\n    inputs={\n        \"prompt\": prompt5,\n        \"resolution\": \"1280x720 ( 16:9 )\",\n        \"steps\": 8,\n        \"random_seed\": True,\n    },\n    postprocess=lambda images, seed_used, seed_number: images[0][\"image\"],\n    outputs={\n        \"image\": gr.Image(label=\"Scene 3\"),\n    },\n)\n\n# Resize images for video transition (smaller images = faster/more reliable)\nresize1 = FnNode(\n    resize_image,\n    name=\"Resize 1\",\n    inputs={\"image_path\": image1.image},\n    outputs={\"resized\": gr.Image()},\n)\nresize2 = FnNode(\n    resize_image,\n    name=\"Resize 2\",\n    inputs={\"image_path\": image2.image},\n    outputs={\"resized\": gr.Image()},\n)\nresize3 = FnNode(\n    resize_image,\n    name=\"Resize 3\",\n    inputs={\"image_path\": image3.image},\n    outputs={\"resized\": gr.Image()},\n)\nresize4 = FnNode(\n    resize_image,\n    name=\"Resize 4\",\n    inputs={\"image_path\": image4.image},\n    outputs={\"resized\": gr.Image()},\n)\nresize5 = FnNode(\n    resize_image,\n    name=\"Resize 5\",\n    inputs={\"image_path\": image5.image},\n    outputs={\"resized\": gr.Image()},\n)\n\ntransition_1_2 = GradioNode(\n    space_or_url=\"multimodalart/wan-2-2-first-last-frame\",\n    api_name=\"/generate_video\",\n    name=\"Transition 1→2\",\n    inputs={\n        \"start_image_pil\": resize1.resized,\n        \"end_image_pil\": resize2.resized,\n        \"prompt\": transition_prompt,\n        \"negative_prompt\": \"blurry, distorted, low quality\",\n        \"duration_seconds\": 2.0,\n        \"steps\": 8,\n        \"guidance_scale\": 1.0,\n        \"randomize_seed\": True,\n    },\n    postprocess=lambda video, seed: video,\n    outputs={\n        \"video\": gr.Video(label=\"Transition 1→2\"),\n    },\n)\n\ntransition_2_3 = GradioNode(\n    space_or_url=\"multimodalart/wan-2-2-first-last-frame\",\n    api_name=\"/generate_video\",\n    name=\"Transition 2→3\",\n    inputs={\n        \"start_image_pil\": resize2.resized,\n        \"end_image_pil\": resize3.resized,\n        \"prompt\": transition_prompt,\n        \"negative_prompt\": \"blurry, distorted, low quality\",\n        \"duration_seconds\": 2.0,\n        \"steps\": 8,\n        \"guidance_scale\": 1.0,\n        \"randomize_seed\": True,\n    },\n    postprocess=lambda video, seed: video,\n    outputs={\n        \"video\": gr.Video(label=\"Transition 2→3\"),\n    },\n)\n\ntransition_3_4 = GradioNode(\n    space_or_url=\"multimodalart/wan-2-2-first-last-frame\",\n    api_name=\"/generate_video\",\n    name=\"Transition 3→4\",\n    inputs={\n        \"start_image_pil\": resize3.resized,\n        \"end_image_pil\": resize4.resized,\n        \"prompt\": transition_prompt,\n        \"negative_prompt\": \"blurry, distorted, low quality\",\n        \"duration_seconds\": 2.0,\n        \"steps\": 8,\n        \"guidance_scale\": 1.0,\n        \"randomize_seed\": True,\n    },\n    postprocess=lambda video, seed: video,\n    outputs={\n        \"video\": gr.Video(label=\"Transition 3→4\"),\n    },\n)\n\ntransition_4_5 = GradioNode(\n    space_or_url=\"multimodalart/wan-2-2-first-last-frame\",\n    api_name=\"/generate_video\",\n    name=\"Transition 4→5\",\n    inputs={\n        \"start_image_pil\": resize4.resized,\n        \"end_image_pil\": resize5.resized,\n        \"prompt\": transition_prompt,\n        \"negative_prompt\": \"blurry, distorted, low quality\",\n        \"duration_seconds\": 2.0,\n        \"steps\": 8,\n        \"guidance_scale\": 1.0,\n        \"randomize_seed\": True,\n    },\n    postprocess=lambda video, seed: video,\n    outputs={\n        \"video\": gr.Video(label=\"Transition 4→5\"),\n    },\n)\n\n\ndef concat_videos(v1: str, v2: str, v3: str, v4: str) -> str:\n    \"\"\"Concatenate 4 transition videos into one slideshow.\"\"\"\n    list_file = tempfile.mktemp(suffix=\".txt\")\n    output_path = tempfile.mktemp(suffix=\".mp4\")\n\n    with open(list_file, \"w\") as f:\n        for v in [v1, v2, v3, v4]:\n            f.write(f\"file '{v}'\\n\")\n\n    cmd = [\n        \"ffmpeg\",\n        \"-y\",\n        \"-f\",\n        \"concat\",\n        \"-safe\",\n        \"0\",\n        \"-i\",\n        list_file,\n        \"-c\",\n        \"copy\",\n        output_path,\n    ]\n    subprocess.run(cmd, check=True, capture_output=True)\n    return output_path\n\n\ncombine_videos = FnNode(\n    concat_videos,\n    name=\"Combine Slideshow\",\n    inputs={\n        \"v1\": transition_1_2.video,\n        \"v2\": transition_2_3.video,\n        \"v3\": transition_3_4.video,\n        \"v4\": transition_4_5.video,\n    },\n    outputs={\n        \"final_video\": gr.Video(label=\"Final Slideshow\"),\n    },\n)\n\ngraph = Graph(\n    name=\"AI Slideshow with Smooth Transitions\",\n    nodes=[\n        image1,\n        image2,\n        image3,\n        image4,\n        image5,\n        resize1,\n        resize2,\n        resize3,\n        resize4,\n        resize5,\n        transition_1_2,\n        transition_2_3,\n        transition_3_4,\n        transition_4_5,\n        combine_videos,\n    ],\n)\n\ngraph.launch()\n"
  },
  {
    "path": "examples/10_real_podcast_app.py",
    "content": "# Showcases a full document-to-podcast pipeline: URL extraction → dialogue generation → TTS → audio combining.\nimport gradio as gr\n\nfrom daggr import FnNode, Graph\n\n\ndef extract_content(url: str, custom_text: str) -> tuple[str, str]:\n    \"\"\"\n    Extracts and cleans text content from a URL or uses custom text.\n    Returns: (cleaned_text, title)\n    \"\"\"\n    import re\n\n    import requests\n    from bs4 import BeautifulSoup\n\n    if custom_text.strip():\n        lines = custom_text.strip().split(\"\\n\")\n        title = lines[0][:50] if lines else \"Custom Content\"\n        return custom_text, title\n\n    if not url.strip():\n        return \"Please provide a URL or paste your content.\", \"No Content\"\n\n    try:\n        headers = {\"User-Agent\": \"Mozilla/5.0\"}\n        response = requests.get(url, headers=headers, timeout=10)\n        soup = BeautifulSoup(response.text, \"html.parser\")\n\n        title = soup.find(\"title\")\n        title = title.text.strip() if title else \"Untitled\"\n\n        for element in soup([\"script\", \"style\", \"nav\", \"footer\", \"header\"]):\n            element.decompose()\n\n        article = soup.find(\"article\") or soup.find(\"main\") or soup.body\n\n        if article:\n            text = article.get_text(separator=\"\\n\")\n            text = re.sub(r\"\\n\\s*\\n\", \"\\n\\n\", text)\n            text = re.sub(r\" +\", \" \", text)\n            text = text.strip()\n\n            if len(text) > 10000:\n                text = text[:10000] + \"...\"\n\n            return text, title\n\n        return \"Could not extract content from URL.\", title\n\n    except Exception as e:\n        return f\"Error fetching URL: {str(e)}\", \"Error\"\n\n\ncontent_extractor = FnNode(\n    fn=extract_content,\n    inputs={\n        \"url\": gr.Textbox(\n            label=\"🔗 Article URL\",\n            placeholder=\"https://github.com/gradio-app/daggr/blob/main/README.md\",\n            value=\"\",\n        ),\n        \"custom_text\": gr.Textbox(\n            label=\"📝 Or paste your content directly\",\n            placeholder=\"Paste article text, research paper, or any content here...\",\n            lines=5,\n        ),\n    },\n    outputs={\n        \"content\": gr.Textbox(label=\"📄 Extracted Content\", lines=10),\n        \"title\": gr.Textbox(label=\"📰 Title\"),\n    },\n)\n\n\ndef generate_dialogue(\n    content: str, title: str, host_style: str, episode_length: str\n) -> tuple[list, str]:\n    \"\"\"\n    Generates a natural conversation script between two podcast hosts.\n    Returns: (dialogue_lines for scatter, html_preview)\n\n    In production, this would use an LLM. Demo shows expected structure.\n    \"\"\"\n    exchanges = {\n        \"Short (2-3 min)\": 6,\n        \"Medium (5-7 min)\": 12,\n        \"Long (10+ min)\": 20,\n    }.get(episode_length, 8)\n\n    dialogue = []\n\n    dialogue.append(\n        {\n            \"speaker\": \"host\",\n            \"text\": f\"Welcome back to the show! Today we're diving into something fascinating: {title}. I've been really excited to discuss this one.\",\n            \"voice_style\": host_style,\n        }\n    )\n\n    dialogue.append(\n        {\n            \"speaker\": \"guest\",\n            \"text\": \"Me too! When I first read through this, I was struck by how relevant it is. There's a lot to unpack here.\",\n            \"voice_style\": \"friendly, curious\",\n        }\n    )\n\n    dialogue.append(\n        {\n            \"speaker\": \"host\",\n            \"text\": \"So let's start with the main point. Can you give our listeners the key takeaway?\",\n            \"voice_style\": host_style,\n        }\n    )\n\n    dialogue.append(\n        {\n            \"speaker\": \"guest\",\n            \"text\": \"Absolutely. The core idea here is really about understanding the bigger picture. The author makes a compelling case that we need to think differently about this topic.\",\n            \"voice_style\": \"thoughtful, explaining\",\n        }\n    )\n\n    for i in range((exchanges - 4) // 2):\n        dialogue.append(\n            {\n                \"speaker\": \"host\",\n                \"text\": f\"That's a great point. What really stood out to you in section {i + 1}?\",\n                \"voice_style\": host_style,\n            }\n        )\n        dialogue.append(\n            {\n                \"speaker\": \"guest\",\n                \"text\": \"Well, I think the author's argument about context is particularly strong. It challenges conventional thinking in a productive way.\",\n                \"voice_style\": \"engaged, analytical\",\n            }\n        )\n\n    dialogue.append(\n        {\n            \"speaker\": \"host\",\n            \"text\": \"This has been such a great conversation. Any final thoughts for our listeners?\",\n            \"voice_style\": host_style,\n        }\n    )\n\n    dialogue.append(\n        {\n            \"speaker\": \"guest\",\n            \"text\": \"I'd encourage everyone to check out the original piece. There's so much more depth there. Thanks for having me!\",\n            \"voice_style\": \"warm, grateful\",\n        }\n    )\n\n    dialogue.append(\n        {\n            \"speaker\": \"host\",\n            \"text\": \"Thanks for listening everyone! Don't forget to subscribe and we'll see you next time.\",\n            \"voice_style\": host_style,\n        }\n    )\n\n    html = f\"\"\"\n    <div style=\"font-family: Georgia, serif; max-width: 600px; margin: 0 auto; padding: 20px; background: #ffffff; border-radius: 12px;\">\n        <h2 style=\"border-bottom: 2px solid #333; padding-bottom: 10px; color: #1a1a1a;\">🎙️ {title}</h2>\n        <p style=\"color: #1a1a1a; font-style: italic;\">Episode Preview • {len(dialogue)} segments</p>\n    \"\"\"\n\n    for line in dialogue[:6]:\n        speaker_color = \"#2563eb\" if line[\"speaker\"] == \"host\" else \"#059669\"\n        speaker_label = \"🎤 Host\" if line[\"speaker\"] == \"host\" else \"🗣️ Guest\"\n        html += f\"\"\"\n        <div style=\"margin: 15px 0; padding: 10px; background: {\"#f0f9ff\" if line[\"speaker\"] == \"host\" else \"#f0fdf4\"}; border-radius: 8px;\">\n            <strong style=\"color: {speaker_color};\">{speaker_label}</strong>\n            <p style=\"margin: 5px 0 0 0; color: #1a1a1a;\">{line[\"text\"]}</p>\n        </div>\n        \"\"\"\n\n    if len(dialogue) > 6:\n        html += f\"<p style='color: #1a1a1a; text-align: center;'>... and {len(dialogue) - 6} more segments</p>\"\n\n    html += \"</div>\"\n\n    return dialogue, html\n\n\ndialogue_generator = FnNode(\n    fn=generate_dialogue,\n    inputs={\n        \"content\": content_extractor.content,\n        \"title\": content_extractor.title,\n        \"host_style\": gr.Dropdown(\n            label=\"🎭 Host Personality\",\n            choices=[\n                \"enthusiastic, energetic\",\n                \"calm, professional\",\n                \"casual, conversational\",\n                \"intellectual, thoughtful\",\n            ],\n            value=\"enthusiastic, energetic\",\n        ),\n        \"episode_length\": gr.Radio(\n            label=\"⏱️ Episode Length\",\n            choices=[\"Short (2-3 min)\", \"Medium (5-7 min)\", \"Long (10+ min)\"],\n            value=\"Medium (5-7 min)\",\n        ),\n    },\n    outputs={\n        \"dialogue\": gr.JSON(label=\"📋 Dialogue Script\", visible=False),\n        \"preview\": gr.HTML(label=\"👀 Script Preview\"),\n    },\n)\n\n\ndef generate_all_voice_segments(dialogue: list) -> list:\n    \"\"\"\n    Generate TTS audio for ALL dialogue lines in a single node.\n    Bypasses daggr's scatter/gather which has a bug.\n    \"\"\"\n    from gradio_client import Client\n\n    client = Client(\"ysharma/Qwen3-TTS\")\n    audio_files = []\n\n    for i, item in enumerate(dialogue):\n        print(f\"Generating audio for segment {i + 1}/{len(dialogue)}...\")\n        try:\n            result = client.predict(\n                text=item[\"text\"],\n                language=\"Auto\",\n                voice_description=item.get(\"voice_style\", \"friendly\"),\n                api_name=\"/generate_voice_design\",\n            )\n            audio_path = result[0] if isinstance(result, tuple) else result\n            audio_files.append(audio_path)\n            print(f\"  ✓ Generated: {audio_path}\")\n        except Exception as e:\n            print(f\"  ✗ Error on segment {i + 1}: {e}\")\n            audio_files.append(None)\n\n    return audio_files\n\n\nvoice_generator = FnNode(\n    fn=generate_all_voice_segments,\n    inputs={\n        \"dialogue\": dialogue_generator.dialogue,\n    },\n    outputs={\n        \"audio_files\": gr.JSON(label=\"🔊 Generated Audio Files\", visible=False),\n    },\n)\n\n\ndef combine_podcast(\n    audio_files: list,\n    dialogue: list,\n    title: str,\n) -> tuple[str, str]:\n    \"\"\"\n    Combines all audio segments into a final podcast episode.\n    \"\"\"\n    import tempfile\n\n    from pydub import AudioSegment\n\n    combined = AudioSegment.silent(duration=500)\n    successful_segments = 0\n\n    for i, audio_path in enumerate(audio_files):\n        if audio_path:\n            try:\n                segment = AudioSegment.from_file(audio_path)\n                combined += segment\n                pause_duration = 300 if i < len(dialogue) - 1 else 0\n                combined += AudioSegment.silent(duration=pause_duration)\n                successful_segments += 1\n            except Exception as e:\n                print(f\"Error loading segment {i}: {e}\")\n                continue\n\n    combined += AudioSegment.silent(duration=1000)\n    combined = combined.normalize()\n\n    output_path = tempfile.mktemp(suffix=\".mp3\")\n    combined.export(output_path, format=\"mp3\", bitrate=\"192k\")\n\n    duration_mins = len(combined) / 60000\n    summary = f\"\"\"\n    <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px; border-radius: 16px; font-family: Arial, sans-serif;\">\n    <h2 style=\"margin: 0 0 12px 0; font-size: 18px;\">🎙️ Podcast Ready!</h2>\n    <h3 style=\"margin: 0 0 12px 0; font-weight: normal; opacity: 0.9; font-size: 14px;\">{title}</h3>\n    <div style=\"display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; text-align: center;\">\n        <div style=\"background: rgba(255,255,255,0.2); padding: 8px; border-radius: 8px;\">\n            <div style=\"font-size: 20px; font-weight: bold;\">{duration_mins:.1f}</div>\n            <div style=\"font-size: 11px; opacity: 0.8;\">minutes</div>\n        </div>\n        <div style=\"background: rgba(255,255,255,0.2); padding: 8px; border-radius: 8px;\">\n            <div style=\"font-size: 20px; font-weight: bold;\">{successful_segments}</div>\n            <div style=\"font-size: 11px; opacity: 0.8;\">segments</div>\n        </div>\n        <div style=\"background: rgba(255,255,255,0.2); padding: 8px; border-radius: 8px;\">\n            <div style=\"font-size: 20px; font-weight: bold;\">2</div>\n            <div style=\"font-size: 11px; opacity: 0.8;\">speakers</div>\n        </div>\n    </div>\n</div>\n    \"\"\"\n\n    return output_path, summary\n\n\nfinal_podcast = FnNode(\n    fn=combine_podcast,\n    inputs={\n        \"audio_files\": voice_generator.audio_files,\n        \"dialogue\": dialogue_generator.dialogue,\n        \"title\": content_extractor.title,\n    },\n    outputs={\n        \"podcast\": gr.Audio(label=\"🎙️ Final Podcast Episode\"),\n        \"summary\": gr.HTML(label=\"📊 Episode Summary\"),\n    },\n)\n\ngraph = Graph(\n    name=\"🎙️ Document to Podcast Generator\",\n    nodes=[\n        content_extractor,\n        dialogue_generator,\n        voice_generator,\n        final_podcast,\n    ],\n)\n\nif __name__ == \"__main__\":\n    graph.launch()\n"
  },
  {
    "path": "examples/11_viral_content_generator_app.py",
    "content": "# Showcases a social media content pipeline: idea expansion → parallel image/video generation → content packaging.\nimport random\n\nimport gradio as gr\n\nfrom daggr import FnNode, GradioNode, Graph\n\n\ndef expand_content_idea(\n    topic: str, platform: str, tone: str, include_cta: bool\n) -> tuple[str, str, str, str, str]:\n    \"\"\"\n    Expands a simple topic into full content strategy.\n    Returns: (image_prompt, alt_image_prompt, video_prompt, caption, hashtags)\n\n    In production, use an LLM for more creative output.\n    \"\"\"\n\n    platform_styles = {\n        \"Instagram\": {\n            \"aspect\": \"square, centered composition\",\n            \"tone_prefix\": \"aesthetic, instagram-worthy\",\n            \"video_style\": \"smooth transitions, satisfying\",\n        },\n        \"TikTok\": {\n            \"aspect\": \"vertical, dynamic framing\",\n            \"tone_prefix\": \"eye-catching, bold\",\n            \"video_style\": \"fast-paced, trendy\",\n        },\n        \"Twitter/X\": {\n            \"aspect\": \"horizontal, clean design\",\n            \"tone_prefix\": \"attention-grabbing\",\n            \"video_style\": \"informative, quick\",\n        },\n        \"LinkedIn\": {\n            \"aspect\": \"professional, clean\",\n            \"tone_prefix\": \"business-appropriate, polished\",\n            \"video_style\": \"professional, educational\",\n        },\n    }\n\n    style = platform_styles.get(platform, platform_styles[\"Instagram\"])\n\n    base_prompt = f\"{style['tone_prefix']}, {topic}, {tone} mood, {style['aspect']}, high quality, trending\"\n    image_prompt = f\"{base_prompt}, vibrant colors, professional photography style\"\n    alt_image_prompt = f\"{base_prompt}, minimalist design, artistic interpretation\"\n\n    video_prompt = (\n        f\"{topic}, {style['video_style']}, {tone} atmosphere, cinematic, 4k quality\"\n    )\n\n    tone_emojis = {\n        \"Professional\": \"📊\",\n        \"Fun & Playful\": \"🎉\",\n        \"Inspirational\": \"✨\",\n        \"Educational\": \"💡\",\n        \"Trending/Viral\": \"🔥\",\n    }\n    emoji = tone_emojis.get(tone, \"✨\")\n\n    hook = f\"{emoji} {topic.capitalize()}\"\n    body = f\"Here's something that changed my perspective on {topic}...\"\n    cta = \"\\n\\n👇 Drop your thoughts below!\" if include_cta else \"\"\n    caption = f\"{hook}\\n\\n{body}{cta}\"\n\n    topic_words = topic.lower().replace(\",\", \"\").split()\n    base_hashtags = [f\"#{word}\" for word in topic_words[:3] if len(word) > 3]\n    platform_hashtags = {\n        \"Instagram\": [\"#instagood\", \"#photooftheday\", \"#explore\"],\n        \"TikTok\": [\"#fyp\", \"#viral\", \"#trending\"],\n        \"Twitter/X\": [\"#tech\", \"#innovation\"],\n        \"LinkedIn\": [\"#leadership\", \"#growth\", \"#business\"],\n    }\n    all_hashtags = base_hashtags + platform_hashtags.get(platform, [])[:3]\n    hashtags = \" \".join(all_hashtags[:7])\n\n    return image_prompt, alt_image_prompt, video_prompt, caption, hashtags\n\n\ncontent_strategy = FnNode(\n    fn=expand_content_idea,\n    inputs={\n        \"topic\": gr.Textbox(\n            label=\"💡 What's your content about?\",\n            placeholder=\"e.g., AI tools that save time, morning routine tips, startup lessons\",\n            value=\"the future of AI and creativity\",\n            lines=2,\n        ),\n        \"platform\": gr.Dropdown(\n            label=\"📱 Primary Platform\",\n            choices=[\"Instagram\", \"TikTok\", \"Twitter/X\", \"LinkedIn\"],\n            value=\"Instagram\",\n        ),\n        \"tone\": gr.Radio(\n            label=\"🎭 Content Tone\",\n            choices=[\n                \"Professional\",\n                \"Fun & Playful\",\n                \"Inspirational\",\n                \"Educational\",\n                \"Trending/Viral\",\n            ],\n            value=\"Inspirational\",\n        ),\n        \"include_cta\": gr.Checkbox(label=\"📣 Include Call-to-Action\", value=True),\n    },\n    outputs={\n        \"image_prompt\": gr.Textbox(label=\"🖼️ Primary Image Prompt\"),\n        \"alt_image_prompt\": gr.Textbox(label=\"🖼️ Alternative Image Prompt\"),\n        \"video_prompt\": gr.Textbox(label=\"🎬 Video Prompt\"),\n        \"caption\": gr.Textbox(label=\"📝 Caption\", lines=4),\n        \"hashtags\": gr.Textbox(label=\"#️⃣ Hashtags\"),\n    },\n)\n\nprimary_image = GradioNode(\n    space_or_url=\"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": content_strategy.image_prompt,\n        \"seed\": random.randint(0, 999999),\n        \"width\": 1024,\n        \"height\": 1024,\n    },\n    outputs={\n        \"image\": gr.Image(label=\"🖼️ Primary Image\"),\n    },\n)\n\nalt_image = GradioNode(\n    space_or_url=\"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": content_strategy.alt_image_prompt,\n        \"seed\": random.randint(0, 999999),\n        \"width\": 1024,\n        \"height\": 1024,\n    },\n    outputs={\n        \"image\": gr.Image(label=\"🖼️ Alternative Image (A/B Test)\"),\n    },\n)\n\ncontent_video = GradioNode(\n    space_or_url=\"Lightricks/ltx-2-distilled\",\n    api_name=\"/generate_video\",\n    inputs={\n        \"input_image\": primary_image.image,\n        \"prompt\": content_strategy.video_prompt,\n        \"duration\": 3,\n    },\n    outputs={\n        \"video\": gr.Video(label=\"🎬 Animated Content\"),\n        \"seed\": None,\n    },\n)\n\n\ndef package_content(\n    primary_img: str,\n    alt_img: str,\n    video: str,\n    caption: str,\n    hashtags: str,\n    platform: str,\n) -> tuple[str, str]:\n    \"\"\"\n    Packages all content and generates a preview/summary.\n    \"\"\"\n    import json\n\n    package = {\n        \"platform\": platform,\n        \"primary_image\": primary_img,\n        \"alternative_image\": alt_img,\n        \"video\": video,\n        \"caption\": caption,\n        \"hashtags\": hashtags,\n        \"ready_to_post\": all([primary_img, caption]),\n    }\n\n    preview_html = f\"\"\"\n    <div style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 500px; margin: 0 auto;\">\n        \n        <!-- Phone Frame -->\n        <div style=\"background: #000; border-radius: 40px; padding: 20px 12px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);\">\n            \n            <!-- Status Bar -->\n            <div style=\"display: flex; justify-content: space-between; color: white; font-size: 12px; padding: 0 20px 10px;\">\n                <span>9:41</span>\n                <span>📶 100%</span>\n            </div>\n            \n            <!-- App Header -->\n            <div style=\"background: linear-gradient(135deg, #833ab4, #fd1d1d, #fcb045); padding: 12px 16px; color: white; font-weight: bold;\">\n                📱 {platform}\n            </div>\n            \n            <!-- Post Preview -->\n            <div style=\"background: white; padding: 16px;\">\n                \n                <!-- User Info -->\n                <div style=\"display: flex; align-items: center; margin-bottom: 12px;\">\n                    <div style=\"width: 40px; height: 40px; background: linear-gradient(135deg, #833ab4, #fcb045); border-radius: 50%;\"></div>\n                    <div style=\"margin-left: 12px;\">\n                        <div style=\"font-weight: bold; color: #666; font-size: 14px;\">your_brand</div>\n                        <div style=\"font-size: 12px; color: #666;\">Just now</div>\n                    </div>\n                </div>\n                \n                <!-- Image Preview -->\n                <div style=\"background: #f0f0f0; height: 300px; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; overflow: hidden;\">\n                    {\"<img src='/gradio_api/file=\" + primary_img + \"' style='width: 100%; height: 100%; object-fit: cover;' />\" if primary_img else \"<span style='color: #999;'>📷 Image Preview</span>\"}\n                </div>\n                \n                <!-- Caption -->\n                <div style=\"font-size: 14px; line-height: 1.5; color: #666; margin-bottom: 8px;\">\n                    <span style=\"font-weight: bold;\">your_brand</span> {caption[:150]}{\"...\" if len(caption) > 150 else \"\"}\n                </div>\n                \n                <!-- Hashtags -->\n                <div style=\"font-size: 12px; color: #00376b;\">\n                    {hashtags}\n                </div>\n                \n                <!-- Engagement -->\n                <div style=\"display: flex; gap: 16px; margin-top: 12px; padding-top: 12px; color: #333; border-top: 1px solid #eee;\">\n                    <span>❤️ 0</span>\n                    <span>💬 0</span>\n                    <span>📤 Share</span>\n                </div>\n            </div>\n            \n        </div>\n        \n        <!-- Content Summary -->\n        <div style=\"margin-top: 20px; padding: 16px; color: #666; background: #f8fafc; border-radius: 12px;\">\n            <h3 style=\"margin: 0 0 12px 0; font-size: 16px;\">📦 Content Package Ready!</h3>\n            <div style=\"display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; color: #2563eb; font-size: 13px;\">\n                <div>✅ Primary Image</div>\n                <div>✅ A/B Test Image</div>\n                <div>{\"✅\" if video else \"⏳\"} Video Content</div>\n                <div>✅ Caption & Hashtags</div>\n            </div>\n        </div>\n        \n    </div>\n    \"\"\"\n\n    return json.dumps(package, indent=2), preview_html\n\n\nfinal_package = FnNode(\n    fn=package_content,\n    inputs={\n        \"primary_img\": primary_image.image,\n        \"alt_img\": alt_image.image,\n        \"video\": content_video.video,\n        \"caption\": content_strategy.caption,\n        \"hashtags\": content_strategy.hashtags,\n        \"platform\": content_strategy.image_prompt,\n    },\n    outputs={\n        \"package_json\": gr.Code(label=\"📦 Content Package (JSON)\", language=\"json\"),\n        \"preview\": gr.HTML(label=\"📱 Post Preview\"),\n    },\n)\n\ngraph = Graph(\n    name=\"📱 Viral Content Generator\",\n    nodes=[\n        content_strategy,\n        primary_image,\n        alt_image,\n        content_video,\n        final_package,\n    ],\n)\n\nif __name__ == \"__main__\":\n    graph.launch()\n"
  },
  {
    "path": "examples/12_ecommerce_product_generator_app.py",
    "content": "# Showcases e-commerce product content automation: image generation → parallel processing (background removal, enhancement, depth map, object detection) → description → multi-language audio.\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\n\ndef ensure_image_path(inputs, key=\"image\"):\n    \"\"\"Convert image dict to filepath for APIs that expect paths.\"\"\"\n    img = inputs.get(key)\n    if isinstance(img, dict) and \"path\" in img:\n        inputs[key] = img[\"path\"]\n    return inputs\n\n\ndef ensure_image_dict(inputs, key=\"f\"):\n    \"\"\"Convert image path to ImageData dict for APIs that expect dicts.\"\"\"\n    img = inputs.get(key)\n    if isinstance(img, str):\n        inputs[key] = {\n            \"path\": img,\n            \"url\": None,\n            \"size\": None,\n            \"orig_name\": None,\n            \"mime_type\": None,\n            \"is_stream\": False,\n            \"meta\": {},\n        }\n    return inputs\n\n\ndef postprocess_flux(result, seed):\n    \"\"\"Normalize FLUX output to consistent dict format.\"\"\"\n    if isinstance(result, str):\n        return {\n            \"path\": result,\n            \"url\": None,\n            \"size\": None,\n            \"orig_name\": None,\n            \"mime_type\": None,\n            \"is_stream\": False,\n            \"meta\": {},\n        }, seed\n    return result, seed\n\n\n# Node 1: Product Image Generation (FLUX.1-schnell)\nproduct_image_gen = GradioNode(\n    \"black-forest-labs/FLUX.1-schnell\",\n    api_name=\"/infer\",\n    inputs={\n        \"prompt\": gr.Textbox(\n            label=\"Product Description\",\n            value=\"Professional product photo of sleek wireless Bluetooth headphones, matte black finish, floating on white background, studio lighting, 8k, commercial photography\",\n            lines=3,\n        ),\n        \"seed\": 0,\n        \"randomize_seed\": True,\n        \"width\": 1024,\n        \"height\": 1024,\n        \"num_inference_steps\": 4,\n    },\n    postprocess=postprocess_flux,\n    outputs={\n        \"result\": gr.Image(label=\"Generated Product Image\"),\n        \"seed\": gr.Number(visible=False),\n    },\n)\n\n# Node 2: Background Removal for clean product shots\nbg_removal = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/png\",\n    preprocess=lambda x: ensure_image_dict(x, \"f\"),\n    inputs={\"f\": product_image_gen.result},\n    outputs={\"output_png_file\": gr.File(label=\"Transparent PNG\")},\n)\n\n# Node 3: Image Enhancement/Upscaling for high-res product images\nimage_enhance = GradioNode(\n    \"finegrain/finegrain-image-enhancer\",\n    api_name=\"/process\",\n    preprocess=lambda x: ensure_image_dict(x, \"input_image\"),\n    inputs={\n        \"input_image\": product_image_gen.result,\n        \"prompt\": \"high quality product photo, sharp details, professional lighting\",\n        \"negative_prompt\": \"blurry, low quality, noise, artifacts\",\n        \"seed\": 42,\n        \"upscale_factor\": 2,\n        \"controlnet_scale\": 0.6,\n        \"controlnet_decay\": 1.0,\n        \"condition_scale\": 6,\n        \"tile_width\": 112,\n        \"tile_height\": 144,\n        \"denoise_strength\": 0.35,\n        \"num_inference_steps\": 18,\n        \"solver\": \"DDIM\",\n    },\n    outputs={\"before__after\": gr.Image(label=\"Enhanced Image\")},\n)\n\n# Node 4: Depth Map Generation for AR/3D product visualization\ndepth_map = GradioNode(\n    \"depth-anything/Depth-Anything-V2\",\n    api_name=\"/on_submit\",\n    preprocess=lambda x: ensure_image_path(x, \"image\"),\n    inputs={\"image\": product_image_gen.result},\n    outputs={\n        \"depth_map_with_slider_view\": gr.Image(label=\"Depth Map\"),\n        \"grayscale_depth_map\": gr.File(label=\"Grayscale Depth\"),\n        \"16bit_raw_output_can_be_considered_as_disparity\": gr.File(visible=False),\n    },\n)\n\n# Node 5: Object Detection (Florence-2)\nobject_detection = GradioNode(\n    \"gokaygokay/Florence-2\",\n    api_name=\"/process_image\",\n    preprocess=lambda x: ensure_image_path(x, \"image\"),\n    inputs={\n        \"image\": product_image_gen.result,\n        \"task_prompt\": \"Object Detection\",\n        \"text_input\": None,\n        \"model_id\": \"microsoft/Florence-2-large\",\n    },\n    outputs={\n        \"output_text\": gr.Textbox(label=\"Detected Objects\"),\n        \"output_image\": gr.Image(label=\"Detection Visualization\"),\n    },\n)\n\n# Node 6: AI Product Description (Moondream2)\nproduct_description = GradioNode(\n    \"vikhyatk/moondream2\",\n    api_name=\"/answer_question\",\n    preprocess=lambda x: ensure_image_path(x, \"img\"),\n    inputs={\n        \"img\": product_image_gen.result,\n        \"prompt\": \"You are a professional e-commerce copywriter. Describe this product in detail for an online store listing. Include key features, design elements, and potential use cases. Be specific and persuasive.\",\n    },\n    outputs={\"response\": gr.Textbox(label=\"Product Description\", lines=5)},\n)\n\n# Node 7: Short Marketing Caption (Florence-2)\nmarketing_caption = GradioNode(\n    \"gokaygokay/Florence-2\",\n    api_name=\"/process_image\",\n    preprocess=lambda x: ensure_image_path(x, \"image\"),\n    inputs={\n        \"image\": product_image_gen.result,\n        \"task_prompt\": \"Caption\",\n        \"text_input\": None,\n        \"model_id\": \"microsoft/Florence-2-large\",\n    },\n    outputs={\n        \"output_text\": gr.Textbox(label=\"Marketing Caption\"),\n        \"output_image\": gr.Image(visible=False),\n    },\n)\n\n# Node 8: US English Audio (Edge-TTS)\naudio_us_english = GradioNode(\n    \"innoai/Edge-TTS-Text-to-Speech\",\n    api_name=\"/tts_interface\",\n    inputs={\n        \"text\": product_description.response,\n        \"voice\": \"en-US-AriaNeural - en-US (Female)\",\n        \"rate\": 0,\n        \"pitch\": 0,\n    },\n    outputs={\n        \"generated_audio\": gr.Audio(label=\"US English Audio\"),\n        \"warning\": gr.Markdown(visible=False),\n    },\n)\n\n# Node 9: UK English Audio (Edge-TTS)\naudio_uk_english = GradioNode(\n    \"innoai/Edge-TTS-Text-to-Speech\",\n    api_name=\"/tts_interface\",\n    inputs={\n        \"text\": product_description.response,\n        \"voice\": \"en-GB-SoniaNeural - en-GB (Female)\",\n        \"rate\": 0,\n        \"pitch\": 0,\n    },\n    outputs={\n        \"generated_audio\": gr.Audio(label=\"UK English Audio\"),\n        \"warning\": gr.Markdown(visible=False),\n    },\n)\n\n# Node 10: Spanish Audio for international markets (Edge-TTS)\naudio_spanish = GradioNode(\n    \"innoai/Edge-TTS-Text-to-Speech\",\n    api_name=\"/tts_interface\",\n    inputs={\n        \"text\": product_description.response,\n        \"voice\": \"es-ES-ElviraNeural - es-ES (Female)\",\n        \"rate\": 0,\n        \"pitch\": 0,\n    },\n    outputs={\n        \"generated_audio\": gr.Audio(label=\"Spanish Audio\"),\n        \"warning\": gr.Markdown(visible=False),\n    },\n)\n\ngraph = Graph(\n    name=\"E-Commerce Product Content Generator\",\n    nodes=[\n        product_image_gen,\n        bg_removal,\n        image_enhance,\n        depth_map,\n        object_detection,\n        product_description,\n        marketing_caption,\n        audio_us_english,\n        audio_uk_english,\n        audio_spanish,\n    ],\n)\n\nif __name__ == \"__main__\":\n    graph.launch()\n"
  },
  {
    "path": "examples/13_accessible_image_description_app.py",
    "content": "# Showcases accessible content creation: generate an image, describe it with a vision model, then convert to speech for visually impaired users.\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph\n\n\ndef postprocess_flux(result, seed):\n    \"\"\"Normalize FLUX output to consistent dict format.\"\"\"\n    if isinstance(result, str):\n        return {\n            \"path\": result,\n            \"url\": None,\n            \"size\": None,\n            \"orig_name\": None,\n            \"mime_type\": None,\n            \"is_stream\": False,\n            \"meta\": {},\n        }, seed\n    return result, seed\n\n\ndef preprocess_moondream(inputs):\n    \"\"\"Convert image dict to filepath for Moondream API.\"\"\"\n    img = inputs.get(\"img\")\n    if isinstance(img, dict) and \"path\" in img:\n        inputs[\"img\"] = img[\"path\"]\n    return inputs\n\n\n# Node 1: Image Generation (FLUX.1-schnell)\nimage_generator = GradioNode(\n    \"black-forest-labs/FLUX.1-schnell\",\n    api_name=\"/infer\",\n    inputs={\n        \"prompt\": gr.Textbox(\n            label=\"Image Prompt\",\n            value=\"A serene Japanese garden with a koi pond and cherry blossoms\",\n            lines=2,\n        ),\n        \"seed\": 0,\n        \"randomize_seed\": True,\n        \"width\": 1024,\n        \"height\": 1024,\n        \"num_inference_steps\": 4,\n    },\n    postprocess=postprocess_flux,\n    outputs={\n        \"result\": gr.Image(label=\"Generated Image\"),\n        \"seed\": gr.Number(visible=False),\n    },\n)\n\n# Node 2: Image Description (Moondream2 vision-language model)\nimage_describer = GradioNode(\n    \"vikhyatk/moondream2\",\n    api_name=\"/answer_question\",\n    preprocess=preprocess_moondream,\n    inputs={\n        \"img\": image_generator.result,\n        \"prompt\": \"Describe this image in detail, including colors, mood, and composition.\",\n    },\n    outputs={\n        \"response\": gr.Textbox(label=\"Image Description\", lines=5),\n    },\n)\n\n# Node 3: Text-to-Speech (Edge-TTS) for audio description\ndescription_tts = GradioNode(\n    \"innoai/Edge-TTS-Text-to-Speech\",\n    api_name=\"/tts_interface\",\n    inputs={\n        \"text\": image_describer.response,\n        \"voice\": gr.Dropdown(\n            label=\"Voice\",\n            choices=[\n                \"en-US-AriaNeural - en-US (Female)\",\n                \"en-US-GuyNeural - en-US (Male)\",\n                \"en-GB-SoniaNeural - en-GB (Female)\",\n                \"en-GB-RyanNeural - en-GB (Male)\",\n            ],\n            value=\"en-US-AriaNeural - en-US (Female)\",\n        ),\n        \"rate\": 0,\n        \"pitch\": 0,\n    },\n    outputs={\n        \"generated_audio\": gr.Audio(label=\"Audio Description\"),\n        \"warning\": gr.Markdown(visible=False),\n    },\n)\n\ngraph = Graph(\n    name=\"Accessible Image Description\",\n    nodes=[image_generator, image_describer, description_tts],\n)\n\nif __name__ == \"__main__\":\n    graph.launch()\n"
  },
  {
    "path": "examples/14_food_nutrition_analyzer_app.py",
    "content": "import random\n\nimport gradio as gr\n\nfrom daggr import FnNode, GradioNode, Graph\n\n\ndef ensure_image_path(inputs, key=\"image\"):\n    img = inputs.get(key)\n    if isinstance(img, dict) and \"path\" in img:\n        inputs[key] = img[\"path\"]\n    return inputs\n\n\ndef create_nutrition_report(food_items: str, nutrition_analysis: str) -> str:\n    import datetime\n\n    report = f\"\"\"\n# 🍽️ FOOD NUTRITION ANALYSIS REPORT\n**Analyzed:** {datetime.datetime.now().strftime(\"%B %d, %Y at %I:%M %p\")}\n\n---\n\n## 🥗 IDENTIFIED FOODS & DESCRIPTION\n{food_items}\n\n---\n\n## 📊 NUTRITIONAL ANALYSIS\n{nutrition_analysis}\n\n---\n\n## 🏷️ DIETARY CLASSIFICATIONS\n\nBased on the detected foods, this meal may be:\n- ✅ **Vegetarian**: Check analysis above\n- ✅ **Vegan**: Check analysis above  \n- ✅ **Gluten-Free**: Check analysis above\n- ✅ **Dairy-Free**: Check analysis above\n- ✅ **Keto-Friendly**: Check analysis above\n- ✅ **Low-Carb**: Check analysis above\n- ✅ **High-Protein**: Check analysis above\n\n---\n\n## 📈 HEALTH INSIGHTS\n\n### Nutritional Highlights:\n- **Estimated Caloric Range**: See detailed analysis above\n- **Macronutrient Balance**: Carbs/Protein/Fat ratio\n- **Micronutrients**: Vitamins and minerals present\n- **Fiber Content**: Digestive health benefits\n\n### Dietary Recommendations:\n- 💧 Remember to stay hydrated (8 glasses of water daily)\n- 🥗 Balance your plate (50% vegetables, 25% protein, 25% carbs)\n- ⏰ Consider portion sizes for your dietary goals\n- 🏃 Pair with regular physical activity\n\n---\n\n## ⚠️ ALLERGEN WARNINGS\n\nCommon allergens to check for:\n- [ ] Nuts and tree nuts\n- [ ] Dairy products\n- [ ] Gluten/wheat\n- [ ] Shellfish/seafood\n- [ ] Eggs\n- [ ] Soy products\n\n*Note: Always verify ingredients if you have food allergies*\n\n---\n\n## 🎯 MEAL TIMING SUGGESTIONS\n\n**Best consumed:**\n- 🌅 Breakfast: High-protein, complex carbs\n- 🌞 Lunch: Balanced, moderate portions\n- 🌙 Dinner: Lighter, easily digestible\n- 🏋️ Post-Workout: Protein-rich recovery meal\n\n---\n\n## 📱 TRACK YOUR NUTRITION\n\nConsider logging this meal in a nutrition tracking app:\n- MyFitnessPal\n- Cronometer  \n- Lose It!\n- Nutritionix\n\n**Tip:** Take photos of your meals to maintain a visual food diary!\n\"\"\"\n    return report\n\n\ndef extract_calorie_summary(nutrition_analysis: str) -> str:\n    summary = \"\"\"🔢 **QUICK CALORIE & MACRO SUMMARY**\n\nBased on the nutritional analysis:\n\n**Total Estimated Calories:** Check detailed analysis\n**Protein:** Estimate per serving\n**Carbohydrates:** Estimate per serving  \n**Fats:** Estimate per serving\n**Fiber:** Estimate per serving\n\n💡 *These are estimates. Actual values depend on portion sizes, cooking methods, and specific ingredients.*\n\n📊 **Calorie Distribution:**\n- Protein: ~30-35%\n- Carbs: ~40-45%\n- Fats: ~20-30%\n\n🎯 **Portion Control Tips:**\n- Use smaller plates\n- Measure protein portions (palm-sized)\n- Fill half your plate with vegetables\n- Drink water before meals\n\"\"\"\n    return summary\n\n\ngenerated_food = GradioNode(\n    \"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": gr.Textbox(\n            label=\"🎨 Generate Food Image (Optional)\",\n            value=\"Professional food photography of a healthy salmon bowl with quinoa, avocado, cherry tomatoes, and mixed greens, overhead shot, natural lighting, 4k\",\n            lines=3,\n        ),\n        \"height\": 1024,\n        \"width\": 1024,\n        \"seed\": random.random,\n    },\n    outputs={\n        \"image\": gr.Image(label=\"Generated Food Image\"),\n    },\n)\n\nuploaded_food = gr.Image(\n    label=\"📸 OR Upload Your Food Photo\",\n    type=\"filepath\",\n    value=None,\n)\n\nfood_detection = GradioNode(\n    \"gokaygokay/Florence-2\",\n    api_name=\"/process_image\",\n    preprocess=lambda x: ensure_image_path(x, \"image\"),\n    inputs={\n        \"image\": generated_food.image,\n        \"task_prompt\": \"Dense Region Caption\",\n        \"text_input\": None,\n        \"model_id\": \"microsoft/Florence-2-large\",\n    },\n    outputs={\n        \"output_text\": gr.Textbox(label=\"🍕 Detected Food Items & Description\"),\n        \"output_image\": gr.Image(label=\"🔍 Food Analysis View\"),\n    },\n)\n\nnutrition_analysis = GradioNode(\n    \"vikhyatk/moondream2\",\n    api_name=\"/answer_question\",\n    preprocess=lambda x: ensure_image_path(x, \"img\"),\n    inputs={\n        \"img\": generated_food.image,\n        \"prompt\": \"\"\"You are a certified nutritionist. Analyze this food image:\n\n## FOOD IDENTIFICATION\nList all visible food items and ingredients\n\n## NUTRITIONAL BREAKDOWN\n• Estimated calories (per serving and total)\n• Protein (grams)\n• Carbohydrates (grams)\n• Fats (grams)\n• Key vitamins and minerals\n\n## DIETARY CLASSIFICATION\n• Vegan/Vegetarian/Omnivore\n• Gluten-free/Keto/Low-carb status\n• Allergen warnings\n\n## HEALTH ASSESSMENT\n• Nutritional rating (1-10)\n• Portion size evaluation\n• Best meal timing\n• Improvement suggestions\n\nProvide specific quantities and actionable insights.\"\"\",\n    },\n    outputs={\"response\": gr.Textbox(label=\"🥗 Detailed Nutrition Analysis\", lines=15)},\n)\n\nnutrition_report = FnNode(\n    fn=create_nutrition_report,\n    inputs={\n        \"food_items\": food_detection.output_text,\n        \"nutrition_analysis\": nutrition_analysis.response,\n    },\n    outputs={\n        \"report\": gr.Markdown(label=\"📄 Complete Nutrition Report\"),\n    },\n)\n\ncalorie_summary = FnNode(\n    fn=extract_calorie_summary,\n    inputs={\n        \"nutrition_analysis\": nutrition_analysis.response,\n    },\n    outputs={\n        \"summary\": gr.Textbox(label=\"⚡ Quick Calorie Info\", lines=8),\n    },\n)\n\naudio_summary = GradioNode(\n    \"innoai/Edge-TTS-Text-to-Speech\",\n    api_name=\"/tts_interface\",\n    inputs={\n        \"text\": calorie_summary.summary,\n        \"voice\": \"en-US-JennyNeural - en-US (Female)\",\n        \"rate\": 0,\n        \"pitch\": 0,\n    },\n    outputs={\n        \"generated_audio\": gr.Audio(label=\"🔊 Audio Nutrition Summary\"),\n        \"warning\": gr.Markdown(visible=False),\n    },\n)\n\ngraph = Graph(\n    name=\"🍽️ Food Nutrition & Calorie Analyzer\",\n    nodes=[\n        generated_food,\n        food_detection,\n        nutrition_analysis,\n        nutrition_report,\n        calorie_summary,\n        audio_summary,\n    ],\n)\n\nif __name__ == \"__main__\":\n    graph.launch()\n"
  },
  {
    "path": "examples/15_background_removal_with_input_node.py",
    "content": "# Showcases basic GradioNode chaining: generate an image then remove its background.\nimport random\n\nimport gradio as gr\n\nfrom daggr import GradioNode, Graph, InputNode\n\nparameters = InputNode(\n    \"Parameters-Test\",\n    ports={\n        \"prompt\": gr.Textbox(  # An input node is created for the prompt\n            label=\"Prompt\",\n            value=\"A cheetah in the grassy savanna.\",\n            lines=3,\n        ),\n        \"height\": gr.Slider(\n            label=\"Height\", value=1024, minimum=1024, maximum=4096, step=128\n        ),\n        \"width\": gr.Slider(\n            label=\"Width\", value=1024, minimum=1024, maximum=4096, step=128\n        ),\n    },\n)\n\nglm_image = GradioNode(\n    \"hf-applications/Z-Image-Turbo\",\n    api_name=\"/generate_image\",\n    inputs={\n        \"prompt\": parameters.prompt,\n        \"height\": parameters.height,\n        \"width\": parameters.width,\n        \"seed\": random.random,\n    },\n    outputs={\n        \"image\": gr.Image(label=\"Image\"),  # Display original image\n    },\n)\n\nbackground_remover = GradioNode(\n    \"hf-applications/background-removal\",\n    api_name=\"/image\",\n    inputs={\n        \"image\": glm_image.image,\n    },\n    postprocess=lambda _, final: final,\n    outputs={\n        \"image\": gr.Image(label=\"Final Image\"),  # Display only final image\n    },\n)\n\ngraph = Graph(\n    name=\"Transparent Background Image Generator\", nodes=[glm_image, background_remover]\n)\n\ngraph.launch()\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"daggr\",\n\t\"version\": \"0.1.0\",\n\t\"description\": \"\",\n\t\"private\": true,\n\t\"scripts\": {\n\t\t\"ci:version\": \"pnpm changeset version && node ./.changeset/fix_changelogs.cjs\",\n\t\t\"ci:tag\": \"pnpm changeset tag && git push origin --tags\"\n\t},\n\t\"dependencies\": {\n\t\t\"@changesets/changelog-github\": \"^0.5.0\",\n\t\t\"@changesets/cli\": \"^2.27.1\",\n\t\t\"@changesets/get-dependents-graph\": \"^2.1.3\",\n\t\t\"@changesets/get-github-info\": \"^0.6.0\",\n\t\t\"@manypkg/get-packages\": \"^2.2.1\"\n\t}\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - \"daggr\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"daggr\"\ndescription = \"A Python package\"\nauthors = [\n    { name = \"Abubakar Abid\", email = \"abubakar@example.com\" },\n]\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\ndependencies = [\n    \"fastapi>=0.115.0\",\n    \"gradio>=6.0.0\",\n    \"networkx>=3.0\",\n    \"uvicorn[standard]>=0.34.0\",\n]\nclassifiers = [\n    \"Programming Language :: Python :: 3\",\n    \"License :: OSI Approved :: MIT License\",\n    \"Operating System :: OS Independent\",\n]\ndynamic = [\"version\"]\n\n[project.urls]\nhomepage = \"https://github.com/abidlabs/daggr\"\nrepository = \"https://github.com/abidlabs/daggr\"\n\n[project.optional-dependencies]\ndev = [\n    \"ruff==0.9.3\",\n    \"pytest>=8.0.0,<9.0.0\",\n    \"pytest-xdist>=3.0.0\",\n    \"playwright>=1.40.0\",\n]\n\n[project.scripts]\ndaggr = \"daggr.cli:main\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"daggr\"]\nartifacts = [\n    \"daggr/frontend/dist/\",\n    \"daggr/assets/\",\n]\nexclude = [\n    \"daggr/canvas-component/\",\n    \"daggr/frontend/node_modules/\",\n    \"daggr/frontend/src/\",\n    \"daggr/frontend/index.html\",\n    \"daggr/frontend/package.json\",\n    \"daggr/frontend/package-lock.json\",\n    \"daggr/frontend/vite.config.ts\",\n    \"daggr/frontend/svelte.config.js\",\n    \"daggr/frontend/tsconfig.json\",\n]\n\n[tool.hatch.build.targets.sdist]\ninclude = [\n    \"daggr/**/*.py\",\n    \"daggr/**/*.pyi\",\n    \"daggr/py.typed\",\n    \"daggr/package.json\",\n    \"daggr/assets/*\",\n    \"daggr/frontend/dist/**/*\",\n    \"README.md\",\n    \"LICENSE\",\n]\nexclude = [\n    \"daggr/canvas-component/\",\n]\n\n[tool.hatch.version]\npath = \"daggr/package.json\"\npattern = \".*\\\"version\\\":\\\\s*\\\"(?P<version>[^\\\"]+)\\\"\"\n\n[tool.pytest.ini_options]\naddopts = \"-n auto\"\n\n[tool.ruff]\n# Exclude a variety of commonly ignored directories.\nexclude = [\n    \".git\",\n    \".mypy_cache\",\n    \".ruff_cache\",\n    \".venv\",\n    \"__pycache__\",\n    \"build\",\n    \"dist\",\n]\n\n# Same as Black.\nline-length = 88\nindent-width = 4\n\ntarget-version = \"py310\"\n\n[tool.ruff.lint]\n# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)\nselect = [\"E\", \"F\", \"I\"]\n# Ignore line length violations\nignore = [\"E501\"]\n\n# Allow autofix for all enabled rules (when `--fix`) is provided.\nfixable = [\"ALL\"]\n\n# Allow unused variables when underscore-prefixed.\ndummy-variable-rgx = \"^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$\"\n\n[tool.ruff.format]\n# Use single quotes for strings.\nquote-style = \"double\"\n\n# Indent with spaces, rather than tabs.\nindent-style = \"space\"\n\n# Like Black, respect magic trailing commas.\nskip-magic-trailing-comma = false\n\n# Like Black, automatically detect the appropriate line ending.\nline-ending = \"auto\"\n"
  },
  {
    "path": "tests/README.md",
    "content": "# Tests\n\nThis directory contains Python unit tests which can be run by running `pytest` in the root directory.\n\nAdd your test files here following the naming convention `test_*.py`.\n"
  },
  {
    "path": "tests/conftest.py",
    "content": ""
  },
  {
    "path": "tests/test_api.py",
    "content": "import gradio as gr\nfrom fastapi.testclient import TestClient\n\nfrom daggr import FnNode, Graph\nfrom daggr.server import DaggrServer\n\n\nclass TestWorkflowAPI:\n    def test_simple_two_node_workflow_api(self):\n        def double(x):\n            return x * 2\n\n        def add_ten(y):\n            return y + 10\n\n        node_a = FnNode(\n            double,\n            name=\"doubler\",\n            inputs={\"x\": gr.Number(label=\"Input Number\", value=5)},\n            outputs={\"result\": gr.Number(label=\"Doubled\")},\n        )\n        node_b = FnNode(\n            add_ten,\n            name=\"adder\",\n            inputs={\"y\": node_a.result},\n            outputs={\"result\": gr.Number(label=\"Final Result\")},\n        )\n\n        graph = Graph(\"test_simple\", nodes=[node_b], persist_key=False)\n        server = DaggrServer(graph)\n        client = TestClient(server.app)\n\n        schema_response = client.get(\"/api/schema\")\n        assert schema_response.status_code == 200\n        schema = schema_response.json()\n        assert len(schema[\"subgraphs\"]) == 1\n        assert schema[\"subgraphs\"][0][\"id\"] == \"main\"\n        assert len(schema[\"subgraphs\"][0][\"inputs\"]) == 1\n        assert schema[\"subgraphs\"][0][\"inputs\"][0][\"node\"] == \"doubler\"\n        assert schema[\"subgraphs\"][0][\"inputs\"][0][\"port\"] == \"x\"\n\n        call_response = client.post(\n            \"/api/call\",\n            json={\"inputs\": {\"doubler__x\": 7}},\n        )\n        assert call_response.status_code == 200\n        outputs = call_response.json()[\"outputs\"]\n        assert \"adder\" in outputs\n        assert outputs[\"adder\"][\"result\"] == 24  # (7 * 2) + 10 = 24\n\n    def test_multi_node_chain_workflow_api(self):\n        def step1(a, b):\n            return a + b\n\n        def step2(x):\n            return x * 3\n\n        def step3(val):\n            return val - 5\n\n        node_a = FnNode(\n            step1,\n            name=\"adder\",\n            inputs={\n                \"a\": gr.Number(label=\"First Number\", value=1),\n                \"b\": gr.Number(label=\"Second Number\", value=2),\n            },\n            outputs={\"result\": gr.Number(label=\"Sum\")},\n        )\n        node_b = FnNode(\n            step2,\n            name=\"multiplier\",\n            inputs={\"x\": node_a.result},\n            outputs={\"result\": gr.Number(label=\"Tripled\")},\n        )\n        node_c = FnNode(\n            step3,\n            name=\"subtractor\",\n            inputs={\"val\": node_b.result},\n            outputs={\"result\": gr.Number(label=\"Final\")},\n        )\n\n        graph = Graph(\"test_chain\", nodes=[node_c], persist_key=False)\n        server = DaggrServer(graph)\n        client = TestClient(server.app)\n\n        schema_response = client.get(\"/api/schema\")\n        assert schema_response.status_code == 200\n        schema = schema_response.json()\n        assert len(schema[\"subgraphs\"][0][\"inputs\"]) == 2\n        input_ids = {inp[\"id\"] for inp in schema[\"subgraphs\"][0][\"inputs\"]}\n        assert \"adder__a\" in input_ids\n        assert \"adder__b\" in input_ids\n        assert len(schema[\"subgraphs\"][0][\"outputs\"]) == 1\n        assert schema[\"subgraphs\"][0][\"outputs\"][0][\"node\"] == \"subtractor\"\n\n        call_response = client.post(\n            \"/api/call\",\n            json={\"inputs\": {\"adder__a\": 10, \"adder__b\": 5}},\n        )\n        assert call_response.status_code == 200\n        outputs = call_response.json()[\"outputs\"]\n        assert \"subtractor\" in outputs\n        assert outputs[\"subtractor\"][\"result\"] == 40  # ((10 + 5) * 3) - 5 = 40\n"
  },
  {
    "path": "tests/test_basic.py",
    "content": "import pytest\n\nimport daggr\nfrom daggr import FnNode, Graph\n\n\ndef test_basic():\n    assert daggr.__version__\n\n\ndef test_edge_api_with_typed_ports():\n    def step_a(text: str) -> dict:\n        return {\"output\": text.upper()}\n\n    def step_b(data: str) -> dict:\n        return {\"output\": data + \"!\"}\n\n    node_a = FnNode(fn=step_a)\n    node_b = FnNode(fn=step_b)\n\n    assert \"text\" in dir(node_a)\n    assert \"output\" in dir(node_a)\n    assert node_a._name == \"step_a\"\n    assert node_a.text.name == \"text\"\n\n    graph = Graph(name=\"test-edge-api\")\n    graph.edge(node_a.output, node_b.data)\n\n    assert len(graph._edges) == 1\n    assert graph.get_connections() == [(\"step_a\", \"output\", \"step_b\", \"data\")]\n\n\ndef test_port_validation():\n    def process(text: str) -> dict:\n        return {\"output\": text}\n\n    def consume(data: str) -> dict:\n        return {\"output\": data}\n\n    node1 = FnNode(fn=process)\n    node2 = FnNode(fn=consume)\n\n    graph = Graph(name=\"test-port-validation\")\n    graph.edge(node1.nonexistent_port, node2.data)\n    graph.edge(node1.output, node2.missing_input)\n\n    with pytest.raises(ValueError) as exc_info:\n        graph._validate_edges()\n\n    error_msg = str(exc_info.value)\n    assert \"nonexistent_port\" in error_msg\n    assert \"missing_input\" in error_msg\n    assert \"Available outputs: output\" in error_msg\n    assert \"Available inputs: data\" in error_msg\n"
  },
  {
    "path": "tests/test_cache.py",
    "content": "\"\"\"Tests for cache directory resolution.\"\"\"\n\nimport importlib\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom unittest import mock\n\nimport huggingface_hub.constants\n\nfrom daggr.local_space import _get_spaces_cache_dir\nfrom daggr.state import get_daggr_cache_dir\n\n\ndef test_cache_directories_respect_hf_home_env_var():\n    \"\"\"Test that daggr and spaces cache directories respect HF_HOME env var.\"\"\"\n    with tempfile.TemporaryDirectory() as custom_hf_home:\n        with mock.patch.dict(os.environ, {\"HF_HOME\": custom_hf_home}):\n            importlib.reload(huggingface_hub.constants)\n\n            daggr_cache = get_daggr_cache_dir()\n            spaces_cache = _get_spaces_cache_dir()\n\n            hf_home_path = Path(custom_hf_home)\n            assert daggr_cache.is_relative_to(hf_home_path)\n            assert spaces_cache.is_relative_to(hf_home_path)\n"
  },
  {
    "path": "tests/test_executor.py",
    "content": "from daggr import FnNode, Graph\nfrom daggr.executor import SequentialExecutor\n\n\nclass TestSequentialExecutor:\n    def test_execute_single_fn_node(self):\n        def double(x):\n            return x * 2\n\n        node = FnNode(double, inputs={\"x\": 5})\n        graph = Graph(\"test\", nodes=[node])\n        executor = SequentialExecutor(graph)\n        result = executor.execute_node(\"double\", {})\n        assert result[\"output\"] == 10\n\n    def test_execute_chain(self):\n        def step1(x):\n            return x + 1\n\n        def step2(y):\n            return y * 2\n\n        n1 = FnNode(step1, inputs={\"x\": 10})\n        n2 = FnNode(step2, inputs={\"y\": n1.output})\n        graph = Graph(\"test\", nodes=[n2])\n        executor = SequentialExecutor(graph)\n        executor.execute_node(\"step1\", {})\n        result = executor.execute_node(\"step2\", {})\n        assert result[\"output\"] == 22\n\n    def test_execute_all(self):\n        def add_one(x):\n            return x + 1\n\n        def double(x):\n            return x * 2\n\n        n1 = FnNode(add_one, name=\"add_one\", inputs={\"x\": 3})\n        n2 = FnNode(double, name=\"double\", inputs={\"x\": n1.output})\n        graph = Graph(\"test\", nodes=[n2])\n        executor = SequentialExecutor(graph)\n        results = executor.execute_all({})\n        assert results[\"add_one\"][\"output\"] == 4\n        assert results[\"double\"][\"output\"] == 8\n\n    def test_fn_result_mapping_tuple(self):\n        def multi_output(x):\n            return (x, x * 2)\n\n        node = FnNode(\n            multi_output, inputs={\"x\": 5}, outputs={\"first\": None, \"second\": None}\n        )\n        graph = Graph(\"test\", nodes=[node])\n        executor = SequentialExecutor(graph)\n        result = executor.execute_node(\"multi_output\", {})\n        assert result[\"first\"] == 5\n        assert result[\"second\"] == 10\n\n    def test_user_input_override(self):\n        def process(text):\n            return text.upper()\n\n        node = FnNode(process, inputs={\"text\": \"default\"})\n        graph = Graph(\"test\", nodes=[node])\n        executor = SequentialExecutor(graph)\n        result = executor.execute_node(\"process\", {\"text\": \"hello\"})\n        assert result[\"output\"] == \"HELLO\"\n\n    def test_callable_fixed_input(self):\n        counter = {\"value\": 0}\n\n        def get_next():\n            counter[\"value\"] += 1\n            return counter[\"value\"]\n\n        def process(x):\n            return x\n\n        node = FnNode(process, inputs={\"x\": get_next})\n        graph = Graph(\"test\", nodes=[node])\n        executor = SequentialExecutor(graph)\n        result1 = executor.execute_node(\"process\", {})\n        result2 = executor.execute_node(\"process\", {})\n        assert result1[\"output\"] == 1\n        assert result2[\"output\"] == 2\n"
  },
  {
    "path": "tests/test_nodes.py",
    "content": "import pytest\n\nfrom daggr import FnNode, Graph, InteractionNode\nfrom daggr.port import ItemList, Port, ScatteredPort\n\n\nclass TestComponentTypeWarning:\n    def test_warns_when_type_explicitly_set(self):\n        import gradio as gr\n\n        with pytest.warns(UserWarning, match=\"daggr ignores the `type` parameter\"):\n            FnNode(\n                lambda image: image,\n                inputs={\"image\": gr.Image(type=\"numpy\")},\n                outputs={\"output\": None},\n            )\n\n    def test_no_warning_when_type_not_set(self):\n        import warnings\n\n        import gradio as gr\n\n        with warnings.catch_warnings():\n            warnings.simplefilter(\"error\")\n            FnNode(\n                lambda image: image,\n                inputs={\"image\": gr.Image(label=\"Input\")},\n                outputs={\"output\": None},\n            )\n\n\nclass TestFnNode:\n    def test_creates_from_function(self):\n        def my_func(x: str, y: int) -> dict:\n            return {\"result\": f\"{x}-{y}\"}\n\n        node = FnNode(my_func)\n        assert node._name == \"my_func\"\n        assert \"x\" in node._input_ports\n        assert \"y\" in node._input_ports\n        assert \"output\" in node._output_ports\n\n    def test_custom_name(self):\n        def process(data):\n            return {\"out\": data}\n\n        node = FnNode(process, name=\"CustomProcessor\")\n        assert node._name == \"CustomProcessor\"\n\n    def test_explicit_inputs(self):\n        def process(a, b, c):\n            return {\"out\": a + b + c}\n\n        node = FnNode(process, inputs={\"a\": \"fixed_value\", \"b\": None})\n        assert \"a\" in node._input_ports\n        assert \"b\" in node._input_ports\n        assert node._fixed_inputs[\"a\"] == \"fixed_value\"\n\n    def test_invalid_input_raises_error(self):\n        def process(text):\n            return {\"out\": text}\n\n        with pytest.raises(ValueError) as exc:\n            FnNode(process, inputs={\"wrong_name\": \"value\"})\n        assert \"wrong_name\" in str(exc.value)\n\n    def test_item_list_output(self):\n        def generate_items(prompt):\n            return {\"items\": [{\"text\": \"a\"}, {\"text\": \"b\"}]}\n\n        node = FnNode(generate_items, outputs={\"items\": ItemList(text=None)})\n        assert \"items\" in node._output_ports\n        assert \"items\" in node._item_list_schemas\n        assert \"text\" in node._item_list_schemas[\"items\"]\n\n\nclass TestInteractionNode:\n    def test_default_ports(self):\n        node = InteractionNode()\n        assert \"input\" in node._input_ports\n        assert \"output\" in node._output_ports\n\n    def test_custom_interaction_type(self):\n        node = InteractionNode(interaction_type=\"approve\")\n        assert node._interaction_type == \"approve\"\n\n\nclass TestChoiceNodeName:\n    def test_choice_node_uses_custom_name_in_graph(self):\n        def step_a(x):\n            return {\"output\": x}\n\n        def step_b(x):\n            return {\"output\": x}\n\n        a = FnNode(step_a, name=\"variant_a\")\n        b = FnNode(step_b, name=\"variant_b\")\n        choice = a | b\n        choice.name = \"Music generator\"\n\n        graph = Graph(\"test\", nodes=[choice])\n        assert \"Music generator\" in graph.nodes\n        assert graph.nodes[\"Music generator\"] is choice\n\n\nclass TestPort:\n    def test_port_access(self):\n        def process(x):\n            return {\"y\": x}\n\n        node = FnNode(process)\n        port = node.x\n        assert isinstance(port, Port)\n        assert port.name == \"x\"\n        assert port.node is node\n\n    def test_scattered_port(self):\n        def process(x):\n            return {\"items\": [1, 2, 3]}\n\n        node = FnNode(process, outputs={\"items\": None})\n        scattered = node.items.each\n        assert isinstance(scattered, ScatteredPort)\n        assert scattered.name == \"items\"\n\n    def test_scattered_port_with_key(self):\n        def process(x):\n            return {\"items\": [{\"a\": 1}, {\"a\": 2}]}\n\n        node = FnNode(process, outputs={\"items\": ItemList(a=None)})\n        scattered = node.items.a\n        assert isinstance(scattered, ScatteredPort)\n        assert scattered.item_key == \"a\"\n\n\nclass TestGraphConstruction:\n    def test_requires_name(self):\n        with pytest.raises(ValueError):\n            Graph(name=\"\")\n        with pytest.raises(ValueError):\n            Graph(name=None)\n\n    def test_persist_key_derived_from_name(self):\n        graph = Graph(name=\"My Cool App!\")\n        assert graph.persist_key == \"my_cool_app\"\n\n    def test_persist_key_disabled(self):\n        graph = Graph(name=\"Test\", persist_key=False)\n        assert graph.persist_key is None\n\n    def test_persist_key_custom(self):\n        graph = Graph(name=\"Display Name\", persist_key=\"custom_key\")\n        assert graph.persist_key == \"custom_key\"\n\n    def test_add_nodes_from_init(self):\n        def step_a(x):\n            return {\"output\": x}\n\n        def step_b(y):\n            return {\"output\": y}\n\n        n1 = FnNode(step_a)\n        n2 = FnNode(step_b, inputs={\"y\": n1.output})\n        graph = Graph(\"test\", nodes=[n2])\n        assert \"step_a\" in graph.nodes\n        assert \"step_b\" in graph.nodes\n\n    def test_cycle_detection(self):\n        def step_a(x):\n            return {\"out\": x}\n\n        def step_b(y):\n            return {\"out\": y}\n\n        n1 = FnNode(step_a)\n        n2 = FnNode(step_b)\n        graph = Graph(\"test\", nodes=[n1, n2])\n        graph.edge(n1.out, n2.y)\n        with pytest.raises(ValueError, match=\"cycle\"):\n            graph.edge(n2.out, n1.x)\n\n    def test_execution_order(self):\n        def a(x):\n            return {\"output\": x}\n\n        def b(y):\n            return {\"output\": y}\n\n        def c(z):\n            return {\"output\": z}\n\n        n1 = FnNode(a, name=\"first\")\n        n2 = FnNode(b, name=\"second\")\n        n3 = FnNode(c, name=\"third\")\n        graph = Graph(\"test\", nodes=[n1, n2, n3])\n        graph.edge(n1.output, n2.y)\n        graph.edge(n2.output, n3.z)\n        order = graph.get_execution_order()\n        assert order.index(\"first\") < order.index(\"second\")\n        assert order.index(\"second\") < order.index(\"third\")\n\n    def test_get_connections(self):\n        def a(x):\n            return {\"output\": x}\n\n        def b(y):\n            return {\"output\": y}\n\n        n1 = FnNode(a)\n        n2 = FnNode(b, inputs={\"y\": n1.output})\n        graph = Graph(\"test\", nodes=[n2])\n        connections = graph.get_connections()\n        assert len(connections) == 1\n        assert connections[0] == (\"a\", \"output\", \"b\", \"y\")\n"
  },
  {
    "path": "tests/test_persistence.py",
    "content": "import os\nimport tempfile\n\nimport pytest\n\nfrom daggr.state import SessionState\n\n\n@pytest.fixture\ndef state():\n    with tempfile.NamedTemporaryFile(suffix=\".db\", delete=False) as f:\n        db_path = f.name\n    s = SessionState(db_path=db_path)\n    yield s\n    os.unlink(db_path)\n\n\ndef test_create_sheet(state):\n    sheet_id = state.create_sheet(\"user1\", \"TestGraph\", \"My Sheet\")\n    sheet = state.get_sheet(sheet_id)\n\n    assert sheet is not None\n    assert sheet[\"sheet_id\"] == sheet_id\n    assert sheet[\"user_id\"] == \"user1\"\n    assert sheet[\"graph_name\"] == \"TestGraph\"\n    assert sheet[\"name\"] == \"My Sheet\"\n\n\ndef test_list_sheets_by_user(state):\n    state.create_sheet(\"user1\", \"Graph1\", \"Sheet A\")\n    state.create_sheet(\"user1\", \"Graph1\", \"Sheet B\")\n    state.create_sheet(\"user2\", \"Graph1\", \"Sheet C\")\n\n    user1_sheets = state.list_sheets(\"user1\", \"Graph1\")\n    user2_sheets = state.list_sheets(\"user2\", \"Graph1\")\n\n    assert len(user1_sheets) == 2\n    assert len(user2_sheets) == 1\n    assert all(s[\"name\"] in [\"Sheet A\", \"Sheet B\"] for s in user1_sheets)\n    assert user2_sheets[0][\"name\"] == \"Sheet C\"\n\n\ndef test_rename_sheet(state):\n    sheet_id = state.create_sheet(\"user1\", \"Graph1\", \"Original Name\")\n\n    success = state.rename_sheet(sheet_id, \"New Name\")\n\n    assert success is True\n    sheet = state.get_sheet(sheet_id)\n    assert sheet[\"name\"] == \"New Name\"\n\n\ndef test_save_and_load_inputs(state):\n    sheet_id = state.create_sheet(\"user1\", \"Graph1\")\n\n    state.save_input(sheet_id, \"node1\", \"port_a\", \"hello\")\n    state.save_input(sheet_id, \"node1\", \"port_b\", 123)\n    state.save_input(sheet_id, \"node2\", \"input\", {\"key\": \"value\"})\n\n    inputs = state.get_inputs(sheet_id)\n\n    assert inputs[\"node1\"][\"port_a\"] == \"hello\"\n    assert inputs[\"node1\"][\"port_b\"] == 123\n    assert inputs[\"node2\"][\"input\"] == {\"key\": \"value\"}\n\n\ndef test_save_and_load_results(state):\n    sheet_id = state.create_sheet(\"user1\", \"Graph1\")\n\n    state.save_result(sheet_id, \"node1\", {\"output\": \"result1\"})\n    state.save_result(sheet_id, \"node1\", {\"output\": \"result2\"})\n\n    latest = state.get_latest_result(sheet_id, \"node1\")\n    assert latest == {\"output\": \"result2\"}\n\n    first = state.get_result_by_index(sheet_id, \"node1\", 0)\n    assert first == {\"output\": \"result1\"}\n\n    all_results = state.get_all_results(sheet_id)\n    assert len(all_results[\"node1\"]) == 2\n\n\ndef test_user_isolation(state):\n    sheet_a = state.create_sheet(\"alice\", \"Graph1\", \"Alice's Sheet\")\n    sheet_b = state.create_sheet(\"bob\", \"Graph1\", \"Bob's Sheet\")\n\n    state.save_input(sheet_a, \"input_node\", \"value\", \"alice_data\")\n    state.save_input(sheet_b, \"input_node\", \"value\", \"bob_data\")\n\n    alice_inputs = state.get_inputs(sheet_a)\n    bob_inputs = state.get_inputs(sheet_b)\n\n    assert alice_inputs[\"input_node\"][\"value\"] == \"alice_data\"\n    assert bob_inputs[\"input_node\"][\"value\"] == \"bob_data\"\n\n    alice_sheets = state.list_sheets(\"alice\", \"Graph1\")\n    bob_sheets = state.list_sheets(\"bob\", \"Graph1\")\n    assert len(alice_sheets) == 1\n    assert len(bob_sheets) == 1\n    assert alice_sheets[0][\"sheet_id\"] != bob_sheets[0][\"sheet_id\"]\n\n\ndef test_local_user_fallback(state, monkeypatch):\n    monkeypatch.delenv(\"SPACE_ID\", raising=False)\n\n    user_id = state.get_effective_user_id(None)\n    assert user_id == \"local\"\n\n    user_id = state.get_effective_user_id({\"username\": \"myuser\"})\n    assert user_id == \"myuser\"\n\n\ndef test_spaces_requires_login(state, monkeypatch):\n    monkeypatch.setenv(\"SPACE_ID\", \"some-space-id\")\n\n    user_id = state.get_effective_user_id(None)\n    assert user_id is None\n\n    user_id = state.get_effective_user_id({\"username\": \"hf_user\"})\n    assert user_id == \"hf_user\"\n\n\ndef test_delete_sheet(state):\n    sheet_id = state.create_sheet(\"user1\", \"Graph1\", \"To Delete\")\n    state.save_input(sheet_id, \"node1\", \"port\", \"data\")\n    state.save_result(sheet_id, \"node1\", {\"output\": \"result\"})\n\n    deleted = state.delete_sheet(sheet_id)\n\n    assert deleted is True\n    assert state.get_sheet(sheet_id) is None\n    assert state.get_inputs(sheet_id) == {}\n    assert state.get_all_results(sheet_id) == {}\n"
  },
  {
    "path": "tests/test_server.py",
    "content": "from pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom daggr import Graph\nfrom daggr.server import DaggrServer\n\n\n@pytest.fixture\ndef server():\n    graph = Graph(name=\"test\")\n    return DaggrServer(graph)\n\n\ndef test_file_to_url_converts_windows_paths(server):\n    \"\"\"Would fail on Windows before fix: _file_to_url only checked startswith('/').\"\"\"\n    windows_path = \"C:\\\\Users\\\\Test\\\\.cache\\\\image.png\"\n\n    with patch.object(Path, \"is_absolute\", return_value=True):\n        with patch.object(Path, \"exists\", return_value=True):\n            result = server._file_to_url(windows_path)\n\n    assert result == \"/file/C:/Users/Test/.cache/image.png\"\n\n\ndef test_file_to_url_converts_real_file_paths(server, tmp_path):\n    \"\"\"Verifies real filesystem paths are converted to /file/ URLs.\"\"\"\n    test_file = tmp_path / \"test.png\"\n    test_file.write_bytes(b\"test\")\n\n    result = server._file_to_url(str(test_file))\n\n    assert result.startswith(\"/file/\")\n    assert \"\\\\\" not in result\n"
  },
  {
    "path": "tests/ui/__init__.py",
    "content": ""
  },
  {
    "path": "tests/ui/conftest.py",
    "content": "import os\nimport tempfile\nfrom typing import Generator\n\nimport pytest\nfrom playwright.sync_api import Browser, Page, sync_playwright\n\n\n@pytest.fixture\ndef temp_db():\n    with tempfile.NamedTemporaryFile(suffix=\".db\", delete=False) as f:\n        db_path = f.name\n    yield db_path\n    try:\n        os.unlink(db_path)\n    except OSError:\n        pass\n\n\n@pytest.fixture(scope=\"session\")\ndef browser() -> Generator[Browser, None, None]:\n    with sync_playwright() as p:\n        browser = p.chromium.launch(headless=True)\n        yield browser\n        browser.close()\n\n\n@pytest.fixture\ndef page(\n    browser: Browser, request: pytest.FixtureRequest\n) -> Generator[Page, None, None]:\n    video_option = request.config.getoption(\"--video\", default=None)\n    if video_option == \"on\":\n        context = browser.new_context(\n            record_video_dir=\"test-results/\",\n            record_video_size={\"width\": 1280, \"height\": 720},\n        )\n        page = context.new_page()\n    else:\n        page = browser.new_page()\n    page.set_default_timeout(15000)\n    yield page\n    page.close()\n    if video_option == \"on\":\n        context.close()\n\n\ndef pytest_addoption(parser: pytest.Parser):\n    parser.addoption(\n        \"--video\", action=\"store\", default=None, help=\"Record video: on/off\"\n    )\n"
  },
  {
    "path": "tests/ui/helpers.py",
    "content": "import os\nimport socket\nimport threading\nimport time\n\nimport uvicorn\nfrom playwright.sync_api import Page\n\nfrom daggr import Graph\nfrom daggr.server import DaggrServer\n\n\ndef find_available_port() -> int:\n    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n        s.bind((\"127.0.0.1\", 0))\n        return s.getsockname()[1]\n\n\nclass TestServer(uvicorn.Server):\n    def install_signal_handlers(self):\n        pass\n\n    def run_in_thread(self):\n        self.thread = threading.Thread(target=self.run, daemon=True)\n        self.thread.start()\n        start = time.time()\n        while not self.started:\n            time.sleep(0.01)\n            if time.time() - start > 10:\n                raise RuntimeError(\"Server failed to start\")\n\n    def close(self):\n        self.should_exit = True\n        self.thread.join(timeout=5)\n\n\ndef launch_daggr_server(\n    graph: Graph, temp_db: str, theme=None\n) -> tuple[TestServer, str]:\n    os.environ[\"DAGGR_DB_PATH\"] = temp_db\n    port = find_available_port()\n    server = DaggrServer(graph, theme=theme)\n    config = uvicorn.Config(\n        app=server.app,\n        host=\"127.0.0.1\",\n        port=port,\n        log_level=\"warning\",\n    )\n    test_server = TestServer(config)\n    test_server.run_in_thread()\n    url = f\"http://127.0.0.1:{port}\"\n    return test_server, url\n\n\ndef wait_for_graph_load(page: Page, timeout: int = 15000):\n    page.wait_for_function(\n        \"\"\"() => {\n            const status = document.querySelector('.connection-status');\n            if (status) return false;\n            const nodes = document.querySelectorAll('.node');\n            return nodes.length > 0;\n        }\"\"\",\n        timeout=timeout,\n    )\n"
  },
  {
    "path": "tests/ui/test_basic.py",
    "content": "import gradio as gr\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_nodes_and_edges_render(page: Page, temp_db: str):\n    def double(x):\n        return x * 2\n\n    def add_ten(y):\n        return y + 10\n\n    node_a = FnNode(\n        double,\n        name=\"doubler\",\n        inputs={\"x\": gr.Number(label=\"Input Number\", value=5)},\n        outputs={\"result\": gr.Number(label=\"Doubled\")},\n    )\n    node_b = FnNode(\n        add_ten,\n        name=\"adder\",\n        inputs={\"y\": node_a.result},\n        outputs={\"result\": gr.Number(label=\"Final Result\")},\n    )\n\n    graph = Graph(\"Basic Test\", nodes=[node_b], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        nodes = page.locator(\".node\")\n        expect(nodes).to_have_count(3)\n\n        node_names = page.locator(\".node-name\")\n        names = [node_names.nth(i).text_content() for i in range(node_names.count())]\n        assert \"doubler\" in names\n        assert \"adder\" in names\n\n        edges = page.locator(\".edge-path\")\n        expect(edges).to_have_count(2)\n    finally:\n        server.close()\n\n\ndef test_run_workflow_produces_output(page: Page, temp_db: str):\n    def greet(name):\n        return f\"Hello, {name}!\"\n\n    node = FnNode(\n        greet,\n        name=\"greeter\",\n        inputs={\"name\": gr.Textbox(label=\"Name\", value=\"World\")},\n        outputs={\"greeting\": gr.Textbox(label=\"Greeting\")},\n    )\n\n    graph = Graph(\"Greeting Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        run_btn = page.locator(\".run-btn\").first\n        expect(run_btn).to_be_visible()\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const inputs = document.querySelectorAll('.embedded-components input[type=\"text\"]');\n                for (const inp of inputs) {\n                    if (inp.value && inp.value.includes('Hello')) {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n    finally:\n        server.close()\n\n\ndef test_input_node_accepts_value(page: Page, temp_db: str):\n    def process(text):\n        return text.upper()\n\n    node = FnNode(\n        process,\n        name=\"uppercaser\",\n        inputs={\"text\": gr.Textbox(label=\"Input Text\", value=\"test\")},\n        outputs={\"result\": gr.Textbox(label=\"Uppercase\")},\n    )\n\n    graph = Graph(\"Input Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        input_node = page.locator(\".node:has(.type-badge:text('INPUT'))\")\n        expect(input_node).to_be_visible()\n\n        input_field = input_node.locator(\"input[type='text']\").first\n        expect(input_field).to_be_visible()\n\n        input_field.fill(\"hello world\")\n\n        run_btn = page.locator(\".run-btn\").first\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const inputs = document.querySelectorAll('.embedded-components input[type=\"text\"]');\n                for (const inp of inputs) {\n                    if (inp.value && inp.value.includes('HELLO WORLD')) {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_cancel.py",
    "content": "import time\n\nimport gradio as gr\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_cancel_running_node(page: Page, temp_db: str):\n    def slow_fn(x):\n        time.sleep(30)\n        return x * 2\n\n    node = FnNode(\n        slow_fn,\n        name=\"slow_node\",\n        inputs={\"x\": gr.Number(label=\"Input\", value=5)},\n        outputs={\"result\": gr.Number(label=\"Result\")},\n    )\n\n    graph = Graph(\"Cancel Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        slow_node = page.locator(\".node:has(.node-name:text('slow_node'))\")\n        expect(slow_node).to_be_visible()\n\n        run_btn = slow_node.locator(\".run-btn\")\n        expect(run_btn).to_be_visible()\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const nodes = document.querySelectorAll('.node');\n                for (const node of nodes) {\n                    const name = node.querySelector('.node-name');\n                    if (name && name.textContent === 'slow_node') {\n                        const btn = node.querySelector('.run-btn.running');\n                        return btn !== null;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=10000,\n        )\n\n        stop_btn = slow_node.locator(\".run-btn.running\")\n        expect(stop_btn).to_be_visible()\n        stop_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const nodes = document.querySelectorAll('.node');\n                for (const node of nodes) {\n                    const name = node.querySelector('.node-name');\n                    if (name && name.textContent === 'slow_node') {\n                        const btn = node.querySelector('.run-btn');\n                        return btn && !btn.classList.contains('running');\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=5000,\n        )\n\n        run_btn_after = slow_node.locator(\".run-btn:not(.running)\")\n        expect(run_btn_after).to_be_visible()\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_dependency_hash.py",
    "content": "import os\n\nimport gradio as gr\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import GradioNode, Graph, _client_cache\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_dependency_hash_auto_update_on_stale_cache(page: Page, temp_db: str):\n    tts = GradioNode(\n        \"mrfakename/MeloTTS\",\n        api_name=\"/synthesize\",\n        inputs={\n            \"text\": gr.Textbox(label=\"Text\"),\n            \"speaker\": \"EN-US\",\n            \"speed\": 1.0,\n            \"language\": \"EN\",\n        },\n        outputs={\"audio\": gr.Audio()},\n        validate=False,\n    )\n\n    graph = Graph(\"Hash Tracking Test\", nodes=[tts], persist_key=False)\n\n    stale_hash = \"0\" * 40\n    _client_cache.set_dependency_hash(\"mrfakename/MeloTTS\", stale_hash)\n    assert _client_cache.get_dependency_hash(\"mrfakename/MeloTTS\") == stale_hash\n\n    os.environ[\"DAGGR_DEPENDENCY_CHECK\"] = \"update\"\n    try:\n        graph._check_dependency_hashes()\n    finally:\n        os.environ.pop(\"DAGGR_DEPENDENCY_CHECK\", None)\n\n    updated_hash = _client_cache.get_dependency_hash(\"mrfakename/MeloTTS\")\n    assert updated_hash is not None\n    assert updated_hash != stale_hash, (\n        \"Hash should have been updated from the stale value\"\n    )\n\n    server, url = launch_daggr_server(graph, temp_db)\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        nodes = page.locator(\".node\")\n        expect(nodes).to_have_count(2)\n\n        node_names = page.locator(\".node-name\")\n        names = [node_names.nth(i).text_content() for i in range(node_names.count())]\n        assert \"MeloTTS\" in names\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_image_fix.py",
    "content": "import tempfile\nfrom pathlib import Path\n\nimport gradio as gr\nfrom PIL import Image\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\nLOGO = str(\n    Path(__file__).resolve().parent.parent.parent / \"daggr\" / \"assets\" / \"logo_dark.png\"\n)\n\n\ndef test_image_initial_value_and_none_input(page: Page, temp_db: str):\n    def flip_image(image):\n        if image is None:\n            return None\n        img = Image.open(image)\n        img = img.transpose(Image.FLIP_LEFT_RIGHT)\n        out = Path(tempfile.gettempdir()) / \"daggr_flip_test.png\"\n        img.save(out)\n        return str(out)\n\n    node = FnNode(\n        flip_image,\n        name=\"flip\",\n        inputs={\"image\": gr.Image(label=\"Input Image\", value=LOGO)},\n        outputs={\"flipped\": gr.Image(label=\"Flipped Image\")},\n    )\n\n    graph = Graph(\"Image Fix Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const imgs = document.querySelectorAll('.embedded-components img');\n                for (const img of imgs) {\n                    if (img.src && img.src.includes('/file/') && img.naturalWidth > 0) {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n\n        input_img = page.locator(\".embedded-components img[src*='/file/']\").first\n        expect(input_img).to_be_visible()\n\n        run_btn = page.locator(\".run-btn\").first\n        expect(run_btn).to_be_visible()\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const imgs = document.querySelectorAll('.embedded-components img[src*=\"/file/\"]');\n                return imgs.length >= 2;\n            }\"\"\",\n            timeout=15000,\n        )\n\n        output_imgs = page.locator(\".embedded-components img[src*='/file/']\")\n        expect(output_imgs).to_have_count(2)\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_images.py",
    "content": "import tempfile\nfrom pathlib import Path\n\nimport gradio as gr\nfrom PIL import Image\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_image_output_displays(page: Page, temp_db: str):\n    def generate_image(prompt):\n        img = Image.new(\"RGB\", (100, 100), color=(255, 0, 0))\n        temp_dir = Path(tempfile.gettempdir()) / \"daggr_test_images\"\n        temp_dir.mkdir(exist_ok=True)\n        img_path = temp_dir / \"test_image.png\"\n        img.save(img_path)\n        return str(img_path)\n\n    node = FnNode(\n        generate_image,\n        name=\"image_generator\",\n        inputs={\"prompt\": gr.Textbox(label=\"Prompt\", value=\"red square\")},\n        outputs={\"image\": gr.Image(label=\"Generated Image\")},\n    )\n\n    graph = Graph(\"Image Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        run_btn = page.locator(\".run-btn\").first\n        expect(run_btn).to_be_visible()\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const imgs = document.querySelectorAll('.embedded-components img');\n                for (const img of imgs) {\n                    if (img.src && (img.src.includes('/file/') || img.src.startsWith('data:'))) {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n    finally:\n        server.close()\n\n\ndef test_image_input_and_output(page: Page, temp_db: str):\n    def process_image(image):\n        return image\n\n    node = FnNode(\n        process_image,\n        name=\"image_passthrough\",\n        inputs={\"image\": gr.Image(label=\"Input Image\")},\n        outputs={\"output\": gr.Image(label=\"Output Image\")},\n    )\n\n    graph = Graph(\"Image IO Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        nodes = page.locator(\".node\")\n        expect(nodes).to_have_count(2)\n\n        input_node = page.locator(\".node:has(.type-badge:text('INPUT'))\")\n        expect(input_node).to_be_visible()\n    finally:\n        server.close()\n\n\ndef test_multiple_outputs_with_image(page: Page, temp_db: str):\n    def generate_with_info(prompt):\n        img = Image.new(\"RGB\", (50, 50), color=(0, 255, 0))\n        temp_dir = Path(tempfile.gettempdir()) / \"daggr_test_images\"\n        temp_dir.mkdir(exist_ok=True)\n        img_path = temp_dir / \"green_image.png\"\n        img.save(img_path)\n        return str(img_path), f\"Generated from: {prompt}\"\n\n    node = FnNode(\n        generate_with_info,\n        name=\"multi_output\",\n        inputs={\"prompt\": gr.Textbox(label=\"Prompt\", value=\"green square\")},\n        outputs={\n            \"image\": gr.Image(label=\"Generated\"),\n            \"info\": gr.Textbox(label=\"Info\"),\n        },\n    )\n\n    graph = Graph(\"Multi Output Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        run_btn = page.locator(\".run-btn\").first\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const imgs = document.querySelectorAll('.embedded-components img');\n                const inputs = document.querySelectorAll('.embedded-components input[type=\"text\"]');\n                let hasImage = false;\n                let hasText = false;\n                for (const img of imgs) {\n                    if (img.src && (img.src.includes('/file/') || img.src.startsWith('data:'))) {\n                        hasImage = true;\n                    }\n                }\n                for (const inp of inputs) {\n                    if (inp.value && inp.value.includes('Generated from:')) {\n                        hasText = true;\n                    }\n                }\n                return hasImage && hasText;\n            }\"\"\",\n            timeout=15000,\n        )\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_run_mode.py",
    "content": "import re\n\nimport gradio as gr\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_run_mode_dropdown_and_single_step(page: Page, temp_db: str):\n    def add_one(x):\n        return x + 1\n\n    def double(y):\n        return y * 2\n\n    node_a = FnNode(\n        add_one,\n        name=\"add_one\",\n        inputs={\"x\": gr.Number(label=\"Input\", value=5)},\n        outputs={\"result\": gr.Number(label=\"Plus One\")},\n    )\n    node_b = FnNode(\n        double,\n        name=\"double\",\n        inputs={\"y\": node_a.result},\n        outputs={\"result\": gr.Number(label=\"Doubled\")},\n    )\n\n    graph = Graph(\"Run Mode Test\", nodes=[node_b], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        double_node = page.locator(\".node:has(.node-name:text('double'))\")\n        expect(double_node).to_be_visible()\n\n        run_controls = double_node.locator(\".run-controls\")\n        expect(run_controls).to_be_visible()\n\n        run_mode_toggle = run_controls.locator(\".run-mode-toggle\")\n        expect(run_mode_toggle).to_be_visible()\n        run_mode_toggle.click()\n\n        run_mode_menu = page.locator(\".run-mode-menu\")\n        expect(run_mode_menu).to_be_visible()\n\n        step_option = run_mode_menu.locator(\n            \".run-mode-option:has-text('Run this step')\"\n        )\n        expect(step_option).to_be_visible()\n\n        to_here_option = run_mode_menu.locator(\n            \".run-mode-option:has-text('Run to here')\"\n        )\n        expect(to_here_option).to_be_visible()\n\n        # Default is \"Run to here\"\n        expect(to_here_option).to_have_class(re.compile(r\"active\"))\n\n        # Select \"Run this step\" and verify icon changes to single play\n        step_option.click()\n        expect(run_mode_menu).to_be_hidden()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const nodes = document.querySelectorAll('.node');\n                for (const node of nodes) {\n                    const name = node.querySelector('.node-name');\n                    if (name && name.textContent === 'double') {\n                        const icon = node.querySelector('.run-btn .run-icon-svg');\n                        return icon && !icon.classList.contains('run-icon-double');\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=5000,\n        )\n\n        # Select \"Run to here\" and verify icon changes back to double play\n        run_mode_toggle.click()\n        expect(run_mode_menu).to_be_visible()\n\n        to_here_option = page.locator(\n            \".run-mode-menu .run-mode-option:has-text('Run to here')\"\n        )\n        to_here_option.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const nodes = document.querySelectorAll('.node');\n                for (const node of nodes) {\n                    const name = node.querySelector('.node-name');\n                    if (name && name.textContent === 'double') {\n                        const icon = node.querySelector('.run-btn .run-icon-svg');\n                        return icon && icon.classList.contains('run-icon-double');\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=5000,\n        )\n\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_sheets.py",
    "content": "import gradio as gr\nfrom playwright.sync_api import Page, expect\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_sheets_ui_elements_present(page: Page, temp_db: str):\n    def process(text):\n        return text.upper()\n\n    node = FnNode(\n        process,\n        name=\"processor\",\n        inputs={\"text\": gr.Textbox(label=\"Input\", value=\"test\")},\n        outputs={\"result\": gr.Textbox(label=\"Result\")},\n    )\n\n    graph = Graph(\"Sheets Test\", nodes=[node], persist_key=\"sheets_test\")\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        sheet_selector = page.locator(\".sheet-current\")\n        expect(sheet_selector).to_be_visible(timeout=5000)\n\n        sheet_name = page.locator(\".sheet-name\")\n        expect(sheet_name).to_be_visible()\n        expect(sheet_name).to_contain_text(\"Sheet\")\n    finally:\n        server.close()\n\n\ndef test_create_new_sheet(page: Page, temp_db: str):\n    def echo(text):\n        return text\n\n    node = FnNode(\n        echo,\n        name=\"echo\",\n        inputs={\"text\": gr.Textbox(label=\"Text\", value=\"hello\")},\n        outputs={\"result\": gr.Textbox(label=\"Echo\")},\n    )\n\n    graph = Graph(\"New Sheet Test\", nodes=[node], persist_key=\"new_sheet_test\")\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        sheet_selector = page.locator(\".sheet-current\")\n        expect(sheet_selector).to_be_visible(timeout=5000)\n        sheet_selector.click()\n\n        dropdown = page.locator(\".sheet-dropdown\")\n        expect(dropdown).to_be_visible()\n\n        new_sheet_btn = page.locator(\".sheet-new\")\n        expect(new_sheet_btn).to_be_visible()\n        new_sheet_btn.click()\n\n        expect(page.locator(\".sheet-dropdown\")).not_to_be_visible(timeout=5000)\n\n        sheet_selector.click()\n        sheet_options = page.locator(\".sheet-option\")\n        expect(sheet_options).to_have_count(2, timeout=5000)\n    finally:\n        server.close()\n\n\ndef test_switch_between_sheets(page: Page, temp_db: str):\n    def process(text):\n        return f\"Processed: {text}\"\n\n    node = FnNode(\n        process,\n        name=\"processor\",\n        inputs={\"text\": gr.Textbox(label=\"Input\", value=\"default\")},\n        outputs={\"result\": gr.Textbox(label=\"Result\")},\n    )\n\n    graph = Graph(\"Switch Sheets Test\", nodes=[node], persist_key=\"switch_sheets_test\")\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        input_node = page.locator(\".node:has(.type-badge:text('INPUT'))\")\n        input_field = input_node.locator(\"input[type='text']\").first\n        expect(input_field).to_be_visible()\n\n        input_field.fill(\"Sheet 1 Value\")\n\n        run_btn = page.locator(\".run-btn\").first\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const inputs = document.querySelectorAll('.embedded-components input[type=\"text\"]');\n                for (const inp of inputs) {\n                    if (inp.value && inp.value.includes('Processed:')) {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n\n        sheet_selector = page.locator(\".sheet-current\")\n        sheet_selector.click()\n\n        new_sheet_btn = page.locator(\".sheet-new\")\n        new_sheet_btn.click()\n\n        page.wait_for_timeout(1500)\n\n        wait_for_graph_load(page)\n\n        input_field = page.locator(\n            \".node:has(.type-badge:text('INPUT')) input[type='text']\"\n        ).first\n        expect(input_field).to_be_visible()\n        current_value = input_field.input_value()\n        assert current_value == \"default\" or current_value == \"\"\n\n        sheet_selector = page.locator(\".sheet-current\")\n        sheet_selector.click()\n\n        first_sheet = page.locator(\".sheet-option\").first\n        first_sheet.locator(\".sheet-option-name\").click()\n\n        page.wait_for_timeout(1000)\n        wait_for_graph_load(page)\n\n        input_field = page.locator(\n            \".node:has(.type-badge:text('INPUT')) input[type='text']\"\n        ).first\n        restored_value = input_field.input_value()\n        assert restored_value == \"Sheet 1 Value\"\n    finally:\n        server.close()\n\n\ndef test_result_persists_on_sheet(page: Page, temp_db: str):\n    def double(x):\n        return x * 2\n\n    node = FnNode(\n        double,\n        name=\"doubler\",\n        inputs={\"x\": gr.Number(label=\"Number\", value=5)},\n        outputs={\"result\": gr.Number(label=\"Doubled\")},\n    )\n\n    graph = Graph(\n        \"Persist Result Test\", nodes=[node], persist_key=\"persist_result_test\"\n    )\n    server, url = launch_daggr_server(graph, temp_db)\n\n    try:\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        run_btn = page.locator(\".run-btn\").first\n        run_btn.click()\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const inputs = document.querySelectorAll('.embedded-components input[type=\"number\"]');\n                for (const inp of inputs) {\n                    if (inp.value === '10') {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n\n        page.reload()\n        wait_for_graph_load(page)\n\n        page.wait_for_function(\n            \"\"\"() => {\n                const inputs = document.querySelectorAll('.embedded-components input[type=\"number\"]');\n                for (const inp of inputs) {\n                    if (inp.value === '10') {\n                        return true;\n                    }\n                }\n                return false;\n            }\"\"\",\n            timeout=15000,\n        )\n    finally:\n        server.close()\n"
  },
  {
    "path": "tests/ui/test_theme.py",
    "content": "import gradio as gr\nfrom playwright.sync_api import Page\n\nfrom daggr import FnNode, Graph\nfrom tests.ui.helpers import launch_daggr_server, wait_for_graph_load\n\n\ndef test_theme_support(page: Page, temp_db: str):\n    \"\"\"Test that theme CSS is served and applied correctly.\"\"\"\n\n    def echo(text):\n        return text\n\n    node = FnNode(\n        echo,\n        name=\"echo\",\n        inputs={\"text\": gr.Textbox(label=\"Input\")},\n        outputs={\"result\": gr.Textbox(label=\"Output\")},\n    )\n\n    graph = Graph(\"Theme Test\", nodes=[node], persist_key=False)\n    server, url = launch_daggr_server(graph, temp_db, theme=gr.themes.Soft())\n\n    try:\n        response = page.request.get(f\"{url}/theme.css\")\n        assert response.ok\n        css_content = response.text()\n        assert \"--body-background-fill\" in css_content\n        assert \"--color-accent\" in css_content\n\n        page.emulate_media(color_scheme=\"dark\")\n        page.goto(url)\n        wait_for_graph_load(page)\n\n        has_dark_class = page.evaluate(\"() => document.body.classList.contains('dark')\")\n        assert has_dark_class\n\n        has_accent = page.evaluate(\"\"\"\n            () => {\n                const value = getComputedStyle(document.documentElement).getPropertyValue('--color-accent');\n                return value && value.trim().length > 0;\n            }\n        \"\"\")\n        assert has_accent\n    finally:\n        server.close()\n"
  }
]