feat: Gemini AI integration + zine generator + fix Ollama network

- Add /api/prompt multi-provider endpoint (Gemini Flash/Pro + 4 Ollama models)
- Add /api/gemini/image with fallback cascade (gemini-2.0-flash → imagen-3.0)
- Add /api/zine/outline, /api/zine/page, /api/zine/regenerate-section
- Create folk-zine-gen.ts: 8-page MycroZine generator with editable text
  and per-section regeneration (text + image independently)
- Update folk-prompt.ts: multi-provider model dropdown (Gemini + Ollama)
- Update folk-image-gen.ts: add Gemini provider toggle + new styles
- Connect rspace to ai-internal Docker network for Ollama access
- Fetch GEMINI_API_KEY from Infisical claude-ops/ai (no plaintext secrets)
- Update entrypoint.sh: dual Infisical project support (primary + AI)
- Install @google/generative-ai, @google/genai SDKs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 21:27:11 -08:00
parent 20ef1e9ec4
commit 74a5142349
10 changed files with 1530 additions and 40 deletions

162
bun.lock
View File

@ -8,6 +8,8 @@
"@automerge/automerge": "^2.2.8",
"@aws-sdk/client-s3": "^3.700.0",
"@encryptid/sdk": "file:../encryptid-sdk",
"@google/genai": "^1.43.0",
"@google/generative-ai": "^0.24.1",
"@lit/reactive-element": "^2.0.4",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
@ -189,6 +191,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@google/genai": ["@google/genai@1.43.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw=="],
"@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
@ -227,6 +233,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.0", "", {}, "sha512-HLomZXMmrCFHSRKESF5vklAKsDY7/fsT/ZhqCu3V0UoW/Qbv8wxmO4W9bx4KnCCF2Zak4yuk+AGraK/bPmI4kA=="],
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
@ -239,6 +247,28 @@
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="],
"@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
@ -515,6 +545,8 @@
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@x402/core": ["@x402/core@2.5.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-nUr8HW8WhkU1DvrpUfsRvALy5NF8UWKoFezZOtX61mohxp2lWZpJ2GnvscxDM8nmBAbtIollmksd5z5pj8InXw=="],
@ -531,16 +563,32 @@
"aes-js": ["aes-js@4.0.0-beta.5", "", {}, "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"apg-js": ["apg-js@4.4.0", "", {}, "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
@ -553,8 +601,14 @@
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@ -571,6 +625,12 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@ -583,6 +643,8 @@
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
@ -591,8 +653,24 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
@ -603,6 +681,8 @@
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"imapflow": ["imapflow@1.2.10", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "8.0.1", "pino": "10.3.1", "socks": "2.8.7" } }, "sha512-tqmk0Gj4YBEnGCjjrUYWIf3Z4tzn4iihUcMkBRbafvHF3LqEiYNCSJAAYYbwERFxlikOJ3zzqtEcoxCUTjMv2Q=="],
@ -611,10 +691,22 @@
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
@ -627,16 +719,30 @@
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="],
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"nodemailer": ["nodemailer@6.10.1", "", {}, "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
@ -645,8 +751,16 @@
"ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"perfect-arrows": ["perfect-arrows@0.3.7", "", {}, "sha512-wEN2gerTPVWl3yqoFEF8OeGbg3aRe2sxNUi9rnyYrCsL4JcI6K2tBDezRtqVrYG0BNtsWLdYiiTrYm+X//8yLQ=="],
@ -705,6 +819,8 @@
"prosemirror-view": ["prosemirror-view@1.41.6", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
@ -715,10 +831,16 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@ -729,6 +851,12 @@
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"siwe": ["siwe@2.3.2", "", { "dependencies": { "@spruceid/siwe-parser": "^2.1.2", "@stablelib/random": "^1.0.1", "uri-js": "^4.4.1", "valid-url": "^1.0.9" }, "peerDependencies": { "ethers": "^5.6.8 || ^6.0.8" } }, "sha512-aSf+6+Latyttbj5nMu6GF3doMfv2UYj83hhwZgUF20ky6fTS83uVhkQABdIVnEuS8y1bBdk7p6ltb9SmlhTTlA=="],
@ -743,6 +871,14 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
@ -777,6 +913,14 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@ -813,6 +957,18 @@
"mailparser/nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@ -821,6 +977,12 @@
"ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],

View File

@ -39,6 +39,11 @@ services:
- IMAP_PORT=993
- IMAP_TLS_REJECT_UNAUTHORIZED=false
- TWENTY_API_URL=https://rnetwork.online
- OLLAMA_URL=http://ollama:11434
- INFISICAL_AI_CLIENT_ID=${INFISICAL_AI_CLIENT_ID}
- INFISICAL_AI_CLIENT_SECRET=${INFISICAL_AI_CLIENT_SECRET}
- INFISICAL_AI_PROJECT_SLUG=claude-ops
- INFISICAL_AI_SECRET_PATH=/ai
depends_on:
rspace-db:
condition: service_healthy
@ -150,6 +155,7 @@ services:
- rspace-internal
- payment-network
- rmail-mailcow
- ai-internal
rspace-db:
image: postgres:16-alpine
@ -262,3 +268,5 @@ networks:
name: mailcowdockerized_mailcow-network
external: true
rspace-internal:
ai-internal:
external: true

View File

@ -4,6 +4,8 @@
# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET
# Optional: INFISICAL_PROJECT_SLUG (default: rspace), INFISICAL_ENV (default: prod),
# INFISICAL_URL (default: http://infisical:8080)
# Optional: INFISICAL_AI_CLIENT_ID, INFISICAL_AI_CLIENT_SECRET — fetches AI keys
# from a second Infisical project (INFISICAL_AI_PROJECT_SLUG, INFISICAL_AI_SECRET_PATH)
set -e
@ -11,55 +13,68 @@ INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rspace}"
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
echo "[infisical] No credentials set, starting without secret injection"
exec "$@"
fi
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
# Use Bun's built-in fetch API for HTTP calls and JSON parsing
EXPORTS=$(bun -e "
fetch_secrets() {
# Args: $1=clientId, $2=clientSecret, $3=projectSlug, $4=secretPath, $5=label
bun -e "
(async () => {
try {
const base = process.env.INFISICAL_URL;
const base = '$INFISICAL_URL';
const auth = await fetch(base + '/api/v1/auth/universal-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: process.env.INFISICAL_CLIENT_ID,
clientSecret: process.env.INFISICAL_CLIENT_SECRET
})
body: JSON.stringify({ clientId: '$1', clientSecret: '$2' })
}).then(r => r.json());
if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); }
if (!auth.accessToken) { console.error('[infisical:$5] Auth failed'); process.exit(1); }
const slug = process.env.INFISICAL_PROJECT_SLUG;
const env = process.env.INFISICAL_ENV;
const secrets = await fetch(base + '/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', {
const secrets = await fetch(base + '/api/v3/secrets/raw?workspaceSlug=$3&environment=$INFISICAL_ENV&secretPath=$4&recursive=true', {
headers: { 'Authorization': 'Bearer ' + auth.accessToken }
}).then(r => r.json());
if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); }
if (!secrets.secrets) { console.error('[infisical:$5] No secrets returned'); process.exit(1); }
for (const s of secrets.secrets) {
const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" );
console.log('export ' + s.secretKey + \"='\" + escaped + \"'\");
}
} catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); }
} catch (e) { console.error('[infisical:$5] Error:', e.message); process.exit(1); }
})();
" 2>&1) || {
echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
" 2>&1
}
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
echo "[infisical] No credentials set, starting without secret injection"
exec "$@"
fi
# ── Primary project secrets (rspace) ──
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
EXPORTS=$(fetch_secrets "$INFISICAL_CLIENT_ID" "$INFISICAL_CLIENT_SECRET" "$INFISICAL_PROJECT_SLUG" "/" "primary") || {
echo "[infisical] WARNING: Failed to fetch primary secrets, starting with existing env vars"
exec "$@"
}
# Check if we got export statements or error messages
if echo "$EXPORTS" | grep -q "^export "; then
COUNT=$(echo "$EXPORTS" | grep -c "^export ")
eval "$EXPORTS"
echo "[infisical] Injected ${COUNT} secrets"
echo "[infisical] Injected ${COUNT} secrets from ${INFISICAL_PROJECT_SLUG}"
else
echo "[infisical] WARNING: $EXPORTS"
echo "[infisical] Starting with existing env vars"
fi
# ── AI project secrets (optional second source) ──
if [ -n "$INFISICAL_AI_CLIENT_ID" ] && [ -n "$INFISICAL_AI_CLIENT_SECRET" ]; then
AI_SLUG="${INFISICAL_AI_PROJECT_SLUG:-claude-ops}"
AI_PATH="${INFISICAL_AI_SECRET_PATH:-/ai}"
echo "[infisical] Fetching AI secrets from ${AI_SLUG}${AI_PATH}..."
AI_EXPORTS=$(fetch_secrets "$INFISICAL_AI_CLIENT_ID" "$INFISICAL_AI_CLIENT_SECRET" "$AI_SLUG" "$AI_PATH" "ai") || true
if echo "$AI_EXPORTS" | grep -q "^export "; then
AI_COUNT=$(echo "$AI_EXPORTS" | grep -c "^export ")
eval "$AI_EXPORTS"
echo "[infisical] Injected ${AI_COUNT} AI secrets from ${AI_SLUG}${AI_PATH}"
else
echo "[infisical] WARNING: Failed to fetch AI secrets: $AI_EXPORTS"
fi
fi
exec "$@"

View File

@ -177,7 +177,7 @@ const styles = css`
font-size: 13px;
}
.style-select {
.style-select, .provider-select {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
@ -218,8 +218,10 @@ export class FolkImageGen extends FolkShape {
#images: GeneratedImage[] = [];
#isLoading = false;
#error: string | null = null;
#provider: "fal" | "gemini" = "fal";
#promptInput: HTMLTextAreaElement | null = null;
#styleSelect: HTMLSelectElement | null = null;
#providerSelect: HTMLSelectElement | null = null;
#imageArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
@ -245,12 +247,19 @@ export class FolkImageGen extends FolkShape {
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the image you want to generate..." rows="3"></textarea>
<div class="controls">
<select class="provider-select">
<option value="fal">fal.ai Flux</option>
<option value="gemini">Gemini</option>
</select>
<select class="style-select">
<option value="illustration">Illustration</option>
<option value="photorealistic">Photorealistic</option>
<option value="painting">Painting</option>
<option value="sketch">Sketch</option>
<option value="punk-zine">Punk Zine</option>
<option value="collage">Collage</option>
<option value="vintage">Vintage</option>
<option value="minimalist">Minimalist</option>
</select>
<button class="generate-btn">Generate</button>
</div>
@ -273,10 +282,16 @@ export class FolkImageGen extends FolkShape {
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#styleSelect = wrapper.querySelector(".style-select");
this.#providerSelect = wrapper.querySelector(".provider-select");
this.#imageArea = wrapper.querySelector(".image-area");
this.#generateBtn = wrapper.querySelector(".generate-btn");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Provider select
this.#providerSelect?.addEventListener("change", () => {
this.#provider = (this.#providerSelect?.value as "fal" | "gemini") || "fal";
});
// Generate button handler
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
@ -315,8 +330,8 @@ export class FolkImageGen extends FolkShape {
this.#renderLoading();
try {
// Call backend API (to be implemented)
const response = await fetch("/api/image-gen", {
const endpoint = this.#provider === "gemini" ? "/api/gemini/image" : "/api/image-gen";
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, style }),

View File

@ -244,7 +244,7 @@ export class FolkPrompt extends FolkShape {
#messages: ChatMessage[] = [];
#isStreaming = false;
#error: string | null = null;
#model = "gemini-1.5-flash";
#model = "gemini-flash";
#messagesEl: HTMLElement | null = null;
#promptInput: HTMLTextAreaElement | null = null;
@ -280,10 +280,16 @@ export class FolkPrompt extends FolkShape {
</div>
<div class="input-area">
<select class="model-select">
<option value="gemini-1.5-flash">Gemini Flash</option>
<option value="gemini-1.5-pro">Gemini Pro</option>
<option value="claude-3-haiku">Claude Haiku</option>
<option value="claude-3-sonnet">Claude Sonnet</option>
<optgroup label="Gemini">
<option value="gemini-flash">Gemini Flash</option>
<option value="gemini-pro">Gemini Pro</option>
</optgroup>
<optgroup label="Local (Ollama)">
<option value="llama3.2">Llama 3.2 (3B)</option>
<option value="llama3.1">Llama 3.1 (8B)</option>
<option value="qwen2.5-coder">Qwen Coder (7B)</option>
<option value="mistral-small">Mistral Small (24B)</option>
</optgroup>
</select>
<div class="prompt-row">
<textarea class="prompt-input" placeholder="Type your message..." rows="2"></textarea>
@ -323,7 +329,7 @@ export class FolkPrompt extends FolkShape {
// Model select
this.#modelSelect?.addEventListener("change", () => {
this.#model = this.#modelSelect?.value || "gemini-1.5-flash";
this.#model = this.#modelSelect?.value || "gemini-flash";
});
// Clear button

909
lib/folk-zine-gen.ts Normal file
View File

@ -0,0 +1,909 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 480px;
min-height: 560px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #f59e0b, #ef4444);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
/* ── Phase: Ideation ── */
.ideation {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ideation h3 {
margin: 0;
font-size: 14px;
color: #1e293b;
}
.topic-input {
width: 100%;
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
}
.topic-input:focus {
border-color: #f59e0b;
}
.options-row {
display: flex;
gap: 8px;
}
select {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
background: white;
cursor: pointer;
flex: 1;
}
.generate-btn {
padding: 10px 20px;
background: linear-gradient(135deg, #f59e0b, #ef4444);
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.generate-btn:hover { opacity: 0.9; }
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Phase: Drafts / Feedback ── */
.pages-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
color: #64748b;
}
.page-nav button {
padding: 4px 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 12px;
}
.page-nav button:hover { background: #f1f5f9; }
.page-nav button:disabled { opacity: 0.3; cursor: not-allowed; }
.page-dots {
display: flex;
gap: 4px;
}
.page-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #cbd5e1;
cursor: pointer;
border: none;
padding: 0;
}
.page-dot.active { background: #f59e0b; }
.page-dot.generated { background: #22c55e; }
.page-dot.generating { background: #f59e0b; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.page-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* ── Section rendering ── */
.section {
position: relative;
margin-bottom: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.section:hover {
border-color: #f59e0b;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.section-actions {
display: flex;
gap: 2px;
}
.section-actions button {
padding: 2px 6px;
border: none;
background: transparent;
cursor: pointer;
font-size: 11px;
border-radius: 3px;
color: #64748b;
}
.section-actions button:hover {
background: #e2e8f0;
color: #1e293b;
}
.section-body {
padding: 8px 10px;
}
.section-text {
width: 100%;
border: none;
outline: none;
font-family: inherit;
font-size: 13px;
line-height: 1.5;
color: #1e293b;
background: transparent;
resize: none;
min-height: 24px;
overflow: hidden;
}
.section-text.headline {
font-size: 18px;
font-weight: 700;
}
.section-text.subhead {
font-size: 14px;
font-weight: 500;
color: #475569;
}
.section-text.pullquote {
font-style: italic;
border-left: 3px solid #f59e0b;
padding-left: 10px;
color: #64748b;
}
.section-image {
width: 100%;
border-radius: 4px;
display: block;
}
.section-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
background: #f1f5f9;
border-radius: 4px;
color: #94a3b8;
font-size: 12px;
}
/* ── Feedback input ── */
.feedback-row {
display: flex;
gap: 6px;
padding: 4px 8px 8px;
background: #fffbeb;
border-top: 1px solid #fde68a;
}
.feedback-input {
flex: 1;
padding: 6px 8px;
border: 1px solid #fde68a;
border-radius: 4px;
font-size: 11px;
outline: none;
font-family: inherit;
}
.feedback-input:focus { border-color: #f59e0b; }
.feedback-btn {
padding: 6px 10px;
background: #f59e0b;
color: white;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.feedback-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Bottom bar ── */
.bottom-bar {
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
}
.status {
font-size: 11px;
color: #64748b;
}
/* ── Loading ── */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
flex: 1;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #f59e0b;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
margin: 12px;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
padding: 24px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
/* ── Progress bar ── */
.progress-bar {
height: 3px;
background: #e2e8f0;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #f59e0b, #ef4444);
transition: width 0.5s ease;
}
`;
// ── Types ──
interface ZineSection {
id: string;
type: "text" | "image";
content?: string;
imagePrompt?: string;
imageUrl?: string;
}
interface ZinePage {
pageNumber: number;
type: "cover" | "content" | "cta";
title: string;
sections: ZineSection[];
hashtags: string[];
}
type ZinePhase = "ideation" | "generating" | "editing" | "complete";
declare global {
interface HTMLElementTagNameMap {
"folk-zine-gen": FolkZineGen;
}
}
export class FolkZineGen extends FolkShape {
static override tagName = "folk-zine-gen";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#phase: ZinePhase = "ideation";
#pages: ZinePage[] = [];
#currentPage = 0;
#isLoading = false;
#error: string | null = null;
#style = "punk-zine";
#tone = "informative";
#topic = "";
#generatingPage = 0; // 0 = not generating
#regeneratingSection: string | null = null;
// DOM refs
#contentEl: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>📰</span>
<span>Zine Generator</span>
</span>
<div class="header-actions">
<button class="reset-btn" title="Start over"></button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content"></div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#contentEl = wrapper.querySelector(".content");
const resetBtn = wrapper.querySelector(".reset-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
resetBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#reset();
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
this.#render();
return root;
}
#reset() {
this.#phase = "ideation";
this.#pages = [];
this.#currentPage = 0;
this.#isLoading = false;
this.#error = null;
this.#generatingPage = 0;
this.#regeneratingSection = null;
this.#render();
}
#render() {
if (!this.#contentEl) return;
switch (this.#phase) {
case "ideation":
this.#renderIdeation();
break;
case "generating":
this.#renderGenerating();
break;
case "editing":
case "complete":
this.#renderEditing();
break;
}
}
// ── Ideation Phase ──
#renderIdeation() {
if (!this.#contentEl) return;
this.#contentEl.innerHTML = `
<div class="ideation">
<h3>Create a MycroZine</h3>
<textarea class="topic-input" placeholder="What's your zine about? e.g. 'The future of regenerative economics' or 'A guide to urban foraging'" rows="3">${this.#escapeHtml(this.#topic)}</textarea>
<div class="options-row">
<select class="style-select">
<option value="punk-zine"${this.#style === "punk-zine" ? " selected" : ""}>Punk Zine</option>
<option value="mycelial"${this.#style === "mycelial" ? " selected" : ""}>Mycelial</option>
<option value="minimal"${this.#style === "minimal" ? " selected" : ""}>Minimal</option>
<option value="collage"${this.#style === "collage" ? " selected" : ""}>Collage</option>
<option value="retro"${this.#style === "retro" ? " selected" : ""}>Retro</option>
<option value="academic"${this.#style === "academic" ? " selected" : ""}>Academic</option>
</select>
<select class="tone-select">
<option value="informative"${this.#tone === "informative" ? " selected" : ""}>Informative</option>
<option value="rebellious"${this.#tone === "rebellious" ? " selected" : ""}>Rebellious</option>
<option value="regenerative"${this.#tone === "regenerative" ? " selected" : ""}>Regenerative</option>
<option value="playful"${this.#tone === "playful" ? " selected" : ""}>Playful</option>
<option value="poetic"${this.#tone === "poetic" ? " selected" : ""}>Poetic</option>
</select>
</div>
<button class="generate-btn"${this.#isLoading ? " disabled" : ""}>
${this.#isLoading ? "Generating outline..." : "Generate Zine Outline"}
</button>
${this.#error ? `<div class="error">${this.#escapeHtml(this.#error)}</div>` : ""}
</div>
`;
const topicInput = this.#contentEl.querySelector(".topic-input") as HTMLTextAreaElement;
const styleSelect = this.#contentEl.querySelector(".style-select") as HTMLSelectElement;
const toneSelect = this.#contentEl.querySelector(".tone-select") as HTMLSelectElement;
const genBtn = this.#contentEl.querySelector(".generate-btn") as HTMLButtonElement;
topicInput?.addEventListener("input", () => { this.#topic = topicInput.value; });
topicInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
styleSelect?.addEventListener("change", () => { this.#style = styleSelect.value; });
toneSelect?.addEventListener("change", () => { this.#tone = toneSelect.value; });
genBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generateOutline();
});
}
async #generateOutline() {
if (!this.#topic.trim() || this.#isLoading) return;
this.#isLoading = true;
this.#error = null;
this.#render();
try {
const res = await fetch("/api/zine/outline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topic: this.#topic,
style: this.#style,
tone: this.#tone,
}),
});
if (!res.ok) throw new Error(`Outline generation failed: ${res.statusText}`);
const data = await res.json();
this.#pages = data.pages || [];
this.#currentPage = 0;
this.#phase = "generating";
this.#isLoading = false;
this.#render();
// Start generating page images sequentially
await this.#generateAllPages();
} catch (e: any) {
this.#error = e.message;
this.#isLoading = false;
this.#render();
}
}
// ── Generating Phase ──
#renderGenerating() {
if (!this.#contentEl) return;
const total = this.#pages.length;
const progress = this.#generatingPage > 0 ? ((this.#generatingPage - 1) / total) * 100 : 0;
this.#contentEl.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>Generating page ${this.#generatingPage} of ${total}...</span>
<div class="progress-bar" style="width: 200px;">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<span style="font-size: 11px; color: #94a3b8;">${this.#pages[this.#generatingPage - 1]?.title || ""}</span>
</div>
`;
}
async #generateAllPages() {
for (let i = 0; i < this.#pages.length; i++) {
this.#generatingPage = i + 1;
this.#render();
try {
const res = await fetch("/api/zine/page", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outline: this.#pages[i],
style: this.#style,
tone: this.#tone,
}),
});
if (res.ok) {
const data = await res.json();
// Update image URLs in sections
if (data.images) {
for (const section of this.#pages[i].sections) {
if (section.type === "image" && data.images[section.id]) {
section.imageUrl = data.images[section.id];
}
}
}
}
} catch (e: any) {
console.error(`[zine-gen] Page ${i + 1} generation failed:`, e.message);
}
}
this.#generatingPage = 0;
this.#phase = "editing";
this.#render();
}
// ── Editing Phase (with editable text + per-section regeneration) ──
#renderEditing() {
if (!this.#contentEl) return;
const page = this.#pages[this.#currentPage];
if (!page) return;
const total = this.#pages.length;
const dots = this.#pages.map((p, i) => {
const cls = i === this.#currentPage ? "active" : (p.sections.some(s => s.type === "image" && s.imageUrl) ? "generated" : "");
return `<button class="page-dot ${cls}" data-page="${i}" title="Page ${i + 1}: ${this.#escapeHtml(p.title)}"></button>`;
}).join("");
let sectionsHtml = "";
for (const section of page.sections) {
const isRegenerating = this.#regeneratingSection === section.id;
const sectionLabel = section.id.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
sectionsHtml += `<div class="section" data-section-id="${section.id}">`;
sectionsHtml += `<div class="section-header">
<span>${this.#escapeHtml(sectionLabel)}</span>
<div class="section-actions">
<button class="regen-section-btn" data-section-id="${section.id}" title="Regenerate this section"${isRegenerating ? " disabled" : ""}>
${isRegenerating ? "..." : "↻"}
</button>
</div>
</div>`;
if (section.type === "text") {
const textClass = section.id === "headline" ? "headline" :
section.id === "subhead" ? "subhead" :
section.id === "pullquote" ? "pullquote" : "";
sectionsHtml += `<div class="section-body">
<textarea class="section-text ${textClass}" data-section-id="${section.id}">${this.#escapeHtml(section.content || "")}</textarea>
</div>`;
} else if (section.type === "image") {
sectionsHtml += `<div class="section-body">`;
if (section.imageUrl) {
sectionsHtml += `<img class="section-image" src="${this.#escapeHtml(section.imageUrl)}" alt="${this.#escapeHtml(section.imagePrompt || "")}" loading="lazy" />`;
} else {
sectionsHtml += `<div class="section-image-placeholder">${isRegenerating ? '<div class="spinner" style="width:24px;height:24px;"></div>' : "No image yet — click ↻ to generate"}</div>`;
}
sectionsHtml += `</div>`;
}
// Feedback row for regeneration
sectionsHtml += `<div class="feedback-row" style="display:none" data-feedback-for="${section.id}">
<input class="feedback-input" data-section-id="${section.id}" placeholder="Describe changes..." />
<button class="feedback-btn" data-section-id="${section.id}">Regen</button>
</div>`;
sectionsHtml += `</div>`;
}
this.#contentEl.innerHTML = `
<div class="page-nav">
<button class="prev-btn"${this.#currentPage === 0 ? " disabled" : ""}> Prev</button>
<div class="page-dots">${dots}</div>
<button class="next-btn"${this.#currentPage >= total - 1 ? " disabled" : ""}>Next </button>
</div>
<div class="page-content">
<div style="font-size:11px; color:#94a3b8; margin-bottom:8px;">
Page ${page.pageNumber} of ${total} <strong>${this.#escapeHtml(page.title)}</strong>
${page.hashtags?.length ? `<span style="margin-left:8px;">${page.hashtags.map(t => this.#escapeHtml(t)).join(" ")}</span>` : ""}
</div>
${sectionsHtml}
</div>
<div class="bottom-bar">
<span class="status">${this.#phase === "complete" ? "Complete" : "Editing — click text to edit, ↻ to regenerate sections"}</span>
<button class="generate-btn" style="padding:6px 12px; font-size:11px;">Download All</button>
</div>
${this.#error ? `<div class="error">${this.#escapeHtml(this.#error)}</div>` : ""}
`;
// ── Wire up event listeners ──
// Page navigation
this.#contentEl.querySelector(".prev-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#currentPage > 0) { this.#currentPage--; this.#render(); }
});
this.#contentEl.querySelector(".next-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#currentPage < this.#pages.length - 1) { this.#currentPage++; this.#render(); }
});
// Page dots
for (const dot of this.#contentEl.querySelectorAll(".page-dot")) {
dot.addEventListener("click", (e) => {
e.stopPropagation();
this.#currentPage = parseInt((dot as HTMLElement).dataset.page || "0");
this.#render();
});
}
// Editable text areas — auto-resize and save
for (const textarea of this.#contentEl.querySelectorAll(".section-text") as NodeListOf<HTMLTextAreaElement>) {
// Auto-resize
const autoResize = () => {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
};
autoResize();
textarea.addEventListener("input", () => {
autoResize();
const sectionId = textarea.dataset.sectionId;
if (sectionId) {
const section = page.sections.find(s => s.id === sectionId);
if (section) section.content = textarea.value;
}
});
textarea.addEventListener("pointerdown", (e) => e.stopPropagation());
}
// Regenerate section buttons — show feedback row
for (const btn of this.#contentEl.querySelectorAll(".regen-section-btn") as NodeListOf<HTMLButtonElement>) {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const sectionId = btn.dataset.sectionId!;
const feedbackRow = this.#contentEl!.querySelector(`[data-feedback-for="${sectionId}"]`) as HTMLElement;
if (feedbackRow) {
const isVisible = feedbackRow.style.display !== "none";
// Hide all feedback rows first
for (const row of this.#contentEl!.querySelectorAll(".feedback-row") as NodeListOf<HTMLElement>) {
row.style.display = "none";
}
feedbackRow.style.display = isVisible ? "none" : "flex";
if (!isVisible) {
const input = feedbackRow.querySelector(".feedback-input") as HTMLInputElement;
input?.focus();
}
}
});
}
// Feedback submit buttons
for (const btn of this.#contentEl.querySelectorAll(".feedback-btn") as NodeListOf<HTMLButtonElement>) {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const sectionId = btn.dataset.sectionId!;
const input = this.#contentEl!.querySelector(`.feedback-input[data-section-id="${sectionId}"]`) as HTMLInputElement;
const feedback = input?.value.trim() || "";
this.#regenerateSection(sectionId, feedback);
});
}
// Feedback input enter key
for (const input of this.#contentEl.querySelectorAll(".feedback-input") as NodeListOf<HTMLInputElement>) {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const sectionId = input.dataset.sectionId!;
this.#regenerateSection(sectionId, input.value.trim());
}
});
input.addEventListener("pointerdown", (e) => e.stopPropagation());
}
// Download button
this.#contentEl.querySelector(".bottom-bar .generate-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
this.#downloadZine();
});
}
async #regenerateSection(sectionId: string, feedback: string) {
const page = this.#pages[this.#currentPage];
const section = page?.sections.find(s => s.id === sectionId);
if (!section) return;
this.#regeneratingSection = sectionId;
this.#error = null;
this.#render();
try {
const res = await fetch("/api/zine/regenerate-section", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
section,
pageTitle: page.title,
style: this.#style,
tone: this.#tone,
feedback,
}),
});
if (!res.ok) throw new Error(`Regeneration failed: ${res.statusText}`);
const data = await res.json();
if (data.type === "text" && data.content) {
section.content = data.content;
} else if (data.type === "image" && data.url) {
section.imageUrl = data.url;
}
} catch (e: any) {
this.#error = e.message;
} finally {
this.#regeneratingSection = null;
this.#render();
}
}
#downloadZine() {
// Create a simple HTML document with all pages for printing
const pagesHtml = this.#pages.map(page => {
const sectionsHtml = page.sections.map(s => {
if (s.type === "text") {
const tag = s.id === "headline" ? "h1" : s.id === "subhead" ? "h2" : s.id === "pullquote" ? "blockquote" : "p";
return `<${tag}>${this.#escapeHtml(s.content || "")}</${tag}>`;
}
if (s.type === "image" && s.imageUrl) {
return `<img src="${this.#escapeHtml(s.imageUrl)}" style="max-width:100%;border-radius:8px;" />`;
}
return "";
}).join("\n");
return `<div class="zine-page" style="page-break-after:always;padding:32px;max-width:600px;margin:0 auto;">
${sectionsHtml}
<div style="font-size:10px;color:#999;margin-top:16px;">${page.hashtags?.join(" ") || ""}</div>
</div>`;
}).join("\n");
const doc = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>MycroZine: ${this.#escapeHtml(this.#topic)}</title>
<style>body{font-family:sans-serif;margin:0}h1{font-size:28px}h2{font-size:18px;color:#666}blockquote{border-left:4px solid #f59e0b;padding-left:16px;font-style:italic;color:#666}p{line-height:1.6;font-size:14px}@media print{.zine-page{page-break-after:always}}</style>
</head><body>${pagesHtml}</body></html>`;
const blob = new Blob([doc], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `mycrozine-${Date.now()}.html`;
a.click();
URL.revokeObjectURL(url);
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-zine-gen",
phase: this.#phase,
topic: this.#topic,
style: this.#style,
tone: this.#tone,
pages: this.#pages,
currentPage: this.#currentPage,
};
}
}

View File

@ -37,6 +37,7 @@ export * from "./folk-map";
export * from "./folk-image-gen";
export * from "./folk-video-gen";
export * from "./folk-prompt";
export * from "./folk-zine-gen";
export * from "./folk-transcription";
// Creative Tools Shapes

View File

@ -16,6 +16,8 @@
"@automerge/automerge": "^2.2.8",
"@aws-sdk/client-s3": "^3.700.0",
"@encryptid/sdk": "file:../encryptid-sdk",
"@google/genai": "^1.43.0",
"@google/generative-ai": "^0.24.1",
"@lit/reactive-element": "^2.0.4",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",

View File

@ -136,6 +136,21 @@ app.get("/.well-known/webauthn", (c) => {
);
});
// ── Serve generated files from /data/files/generated/ ──
app.get("/data/files/generated/:filename", async (c) => {
const filename = c.req.param("filename");
if (!filename || filename.includes("..") || filename.includes("/")) {
return c.json({ error: "Invalid filename" }, 400);
}
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const filePath = resolve(dir, filename);
const file = Bun.file(filePath);
if (!(await file.exists())) return c.notFound();
const ext = filename.split(".").pop() || "";
const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
});
// ── Space registry API ──
app.route("/api/spaces", spaces);
@ -500,6 +515,7 @@ app.post("/api/x402-test", async (c) => {
// ── Creative tools API endpoints ──
const FAL_KEY = process.env.FAL_KEY || "";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
// Image generation via fal.ai Flux Pro
app.post("/api/image-gen", async (c) => {
@ -762,6 +778,349 @@ app.post("/api/freecad/:action", async (c) => {
}
});
// ── Multi-provider prompt endpoint ──
const GEMINI_MODELS: Record<string, string> = {
"gemini-flash": "gemini-2.0-flash-exp",
"gemini-pro": "gemini-1.5-pro",
};
const OLLAMA_MODELS: Record<string, string> = {
"llama3.2": "llama3.2:3b",
"llama3.1": "llama3.1:8b",
"qwen2.5-coder": "qwen2.5-coder:7b",
"mistral-small": "mistral-small:24b",
};
app.post("/api/prompt", async (c) => {
const { messages, model = "gemini-flash" } = await c.req.json();
if (!messages?.length) return c.json({ error: "messages required" }, 400);
// Determine provider
if (GEMINI_MODELS[model]) {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const geminiModel = genAI.getGenerativeModel({ model: GEMINI_MODELS[model] });
// Convert chat messages to Gemini contents format
const contents = messages.map((m: { role: string; content: string }) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
try {
const result = await geminiModel.generateContent({ contents });
const text = result.response.text();
return c.json({ content: text });
} catch (e: any) {
console.error("[prompt] Gemini error:", e.message);
return c.json({ error: "Gemini request failed" }, 502);
}
}
if (OLLAMA_MODELS[model]) {
try {
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: OLLAMA_MODELS[model],
messages: messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
stream: false,
}),
});
if (!ollamaRes.ok) {
const err = await ollamaRes.text();
console.error("[prompt] Ollama error:", err);
return c.json({ error: "Ollama request failed" }, 502);
}
const data = await ollamaRes.json();
return c.json({ content: data.message?.content || "" });
} catch (e: any) {
console.error("[prompt] Ollama unreachable:", e.message);
return c.json({ error: "Ollama unreachable" }, 503);
}
}
return c.json({ error: `Unknown model: ${model}` }, 400);
});
// ── Gemini image generation ──
app.post("/api/gemini/image", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { prompt, style, aspect_ratio } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const styleHints: Record<string, string> = {
photorealistic: "photorealistic, high detail, natural lighting, ",
illustration: "digital illustration, clean lines, vibrant colors, ",
painting: "oil painting style, brushstrokes visible, painterly, ",
sketch: "pencil sketch, hand-drawn, line art, ",
"punk-zine": "punk zine aesthetic, xerox texture, high contrast, DIY, rough edges, ",
collage: "cut-and-paste collage, mixed media, layered paper textures, ",
vintage: "vintage aesthetic, retro colors, aged paper texture, ",
minimalist: "minimalist design, simple shapes, limited color palette, ",
};
const enhancedPrompt = (styleHints[style] || "") + prompt;
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const models = ["gemini-2.0-flash-exp", "imagen-3.0-generate-002"];
for (const modelName of models) {
try {
if (modelName.startsWith("gemini")) {
const result = await ai.models.generateContent({
model: modelName,
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `gemini-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
} else {
// Imagen API
const result = await ai.models.generateImages({
model: modelName,
prompt: enhancedPrompt,
config: {
numberOfImages: 1,
aspectRatio: aspect_ratio || "3:4",
},
});
const img = (result as any).generatedImages?.[0];
if (img?.image?.imageBytes) {
const filename = `imagen-${Date.now()}.png`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(img.image.imageBytes, "base64"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
} catch (e: any) {
console.error(`[gemini/image] ${modelName} error:`, e.message);
continue;
}
}
return c.json({ error: "All Gemini image models failed" }, 502);
});
// ── Zine generation endpoints ──
const ZINE_STYLES: Record<string, string> = {
"punk-zine": "Xerox texture, high contrast B&W, DIY collage, hand-drawn typography",
mycelial: "Organic networks, spore patterns, earth tones, fungal textures, interconnected webs",
minimal: "Clean lines, white space, modern sans-serif, subtle gradients",
collage: "Layered imagery, mixed media textures, vintage photographs",
retro: "1970s aesthetic, earth tones, groovy typography, halftone patterns",
academic: "Diagram-heavy, annotated illustrations, infographic elements",
};
const ZINE_TONES: Record<string, string> = {
rebellious: "Defiant, anti-establishment, punk energy",
regenerative: "Hopeful, nature-inspired, healing, systems-thinking",
playful: "Fun, whimsical, approachable",
informative: "Educational, clear, accessible",
poetic: "Lyrical, metaphorical, evocative",
};
app.post("/api/zine/outline", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { topic, style = "punk-zine", tone = "informative", pageCount = 8 } = await c.req.json();
if (!topic) return c.json({ error: "topic required" }, 400);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-1.5-pro" });
const systemPrompt = `You are a zine designer creating a ${pageCount}-page MycroZine.
Style: ${ZINE_STYLES[style] || style}
Tone: ${ZINE_TONES[tone] || tone}
Create a structured outline for a zine about: "${topic}"
Each page has sections that will be generated independently. Return ONLY valid JSON (no markdown fences):
{
"pages": [
{
"pageNumber": 1,
"type": "cover",
"title": "...",
"sections": [
{ "id": "headline", "type": "text", "content": "Main headline text" },
{ "id": "subhead", "type": "text", "content": "Subtitle or tagline" },
{ "id": "hero", "type": "image", "imagePrompt": "Detailed image description for cover art" }
],
"hashtags": ["#tag1"]
},
{
"pageNumber": 2,
"type": "content",
"title": "...",
"sections": [
{ "id": "heading", "type": "text", "content": "Section heading" },
{ "id": "body", "type": "text", "content": "Main body text (2-3 paragraphs)" },
{ "id": "pullquote", "type": "text", "content": "A punchy pull quote" },
{ "id": "visual", "type": "image", "imagePrompt": "Detailed description for page illustration" }
],
"hashtags": ["#tag"]
}
]
}
Rules:
- Page 1 is always "cover" type with headline, subhead, hero image
- Pages 2-${pageCount - 1} are "content" with heading, body, pullquote, visual
- Page ${pageCount} is "cta" (call-to-action) with heading, body, action, visual
- Each image prompt should be highly specific, matching the ${style} style
- Text content should match the ${tone} tone
- Make body text substantive (2-3 short paragraphs each)`;
try {
const result = await model.generateContent(systemPrompt);
const text = result.response.text();
// Parse JSON (strip markdown fences if present)
const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
const outline = JSON.parse(jsonStr);
return c.json(outline);
} catch (e: any) {
console.error("[zine/outline] error:", e.message);
return c.json({ error: "Failed to generate outline" }, 502);
}
});
app.post("/api/zine/page", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { outline, style = "punk-zine", tone = "informative" } = await c.req.json();
if (!outline?.pageNumber) return c.json({ error: "outline required with pageNumber" }, 400);
// Find all image sections and generate them
const imageSections = (outline.sections || []).filter((s: any) => s.type === "image");
const generatedImages: Record<string, string> = {};
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
for (const section of imageSections) {
const styleDesc = ZINE_STYLES[style] || style;
const toneDesc = ZINE_TONES[tone] || tone;
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${outline.title}".`;
try {
const result = await ai.models.generateContent({
model: "gemini-2.0-flash-exp",
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `zine-p${outline.pageNumber}-${section.id}-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
generatedImages[section.id] = `/data/files/generated/${filename}`;
break;
}
}
} catch (e: any) {
console.error(`[zine/page] Image gen failed for ${section.id}:`, e.message);
}
}
return c.json({
pageNumber: outline.pageNumber,
images: generatedImages,
outline,
});
});
app.post("/api/zine/regenerate-section", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { section, pageTitle, style = "punk-zine", tone = "informative", feedback = "" } = await c.req.json();
if (!section?.id) return c.json({ error: "section with id required" }, 400);
if (section.type === "image") {
// Regenerate image with feedback
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const styleDesc = ZINE_STYLES[style] || style;
const toneDesc = ZINE_TONES[tone] || tone;
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${pageTitle}".${feedback ? ` User feedback: ${feedback}` : ""}`;
try {
const result = await ai.models.generateContent({
model: "gemini-2.0-flash-exp",
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `zine-regen-${section.id}-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
return c.json({ sectionId: section.id, type: "image", url: `/data/files/generated/${filename}` });
}
}
return c.json({ error: "No image generated" }, 502);
} catch (e: any) {
console.error("[zine/regenerate-section] image error:", e.message);
return c.json({ error: "Image regeneration failed" }, 502);
}
}
if (section.type === "text") {
// Regenerate text via Gemini
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash-exp" });
const toneDesc = ZINE_TONES[tone] || tone;
const prompt = `Rewrite this zine text section. Section type: "${section.id}". Page title: "${pageTitle}". Tone: ${toneDesc}.
Current text: "${section.content}"
${feedback ? `User feedback: ${feedback}` : "Make it punchier and more engaging."}
Return ONLY the new text, no quotes or explanation.`;
try {
const result = await model.generateContent(prompt);
return c.json({ sectionId: section.id, type: "text", content: result.response.text().trim() });
} catch (e: any) {
console.error("[zine/regenerate-section] text error:", e.message);
return c.json({ error: "Text regeneration failed" }, 502);
}
}
return c.json({ error: `Unknown section type: ${section.type}` }, 400);
});
// ── Auto-provision personal space ──
app.post("/api/spaces/auto-provision", async (c) => {
const token = extractToken(c.req.raw.headers);
@ -1735,6 +2094,10 @@ const server = Bun.serve<WSData>({
}
})();
// Ensure generated files directory exists
import { mkdirSync } from "node:fs";
try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {}
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer)
.then(() => ensureTemplateSeeding())

View File

@ -1356,6 +1356,7 @@
folk-image-gen,
folk-video-gen,
folk-prompt,
folk-zine-gen,
folk-transcription,
folk-video-chat,
folk-obs-note,
@ -1376,31 +1377,32 @@
folk-drawfast,
folk-freecad,
folk-kicad,
folk-zine-gen,
folk-rapp {
position: absolute;
}
.connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat,
folk-google-item, folk-piano, folk-embed, folk-calendar, folk-map,
folk-image-gen, folk-video-gen, folk-prompt, folk-transcription,
folk-image-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
folk-video-chat, folk-obs-note, folk-workflow-block,
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-rapp) {
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) {
cursor: crosshair;
}
.connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat,
folk-google-item, folk-piano, folk-embed, folk-calendar, folk-map,
folk-image-gen, folk-video-gen, folk-prompt, folk-transcription,
folk-image-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
folk-video-chat, folk-obs-note, folk-workflow-block,
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-rapp):hover {
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover {
outline: 2px dashed #3b82f6;
outline-offset: 4px;
}
@ -1790,6 +1792,7 @@
<div class="toolbar-dropdown">
<button id="new-image-gen" title="New AI Image">🎨 AI Image</button>
<button id="new-video-gen" title="New AI Video">🎬 AI Video</button>
<button id="new-zine-gen" title="Zine Generator">📰 Zine Gen</button>
<button id="new-splat" title="New 3D Splat">🔮 3D Splat</button>
<button id="new-blender" title="New 3D Blender">🧊 3D Blender</button>
<button id="new-drawfast" title="New Drawing">✏️ Drawfast</button>
@ -1967,6 +1970,7 @@
FolkImageGen,
FolkVideoGen,
FolkPrompt,
FolkZineGen,
FolkTranscription,
FolkVideoChat,
FolkObsNote,
@ -2863,6 +2867,9 @@
shape = document.createElement("folk-prompt");
// Messages history would need to be restored from data.messages
break;
case "folk-zine-gen":
shape = document.createElement("folk-zine-gen");
break;
case "folk-transcription":
shape = document.createElement("folk-transcription");
// Transcript would need to be restored from data.segments
@ -3101,6 +3108,7 @@
"folk-image-gen": { width: 400, height: 500 },
"folk-video-gen": { width: 450, height: 550 },
"folk-prompt": { width: 450, height: 500 },
"folk-zine-gen": { width: 500, height: 600 },
"folk-transcription": { width: 400, height: 450 },
"folk-video-chat": { width: 480, height: 400 },
"folk-obs-note": { width: 450, height: 500 },
@ -3362,6 +3370,7 @@
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
document.getElementById("new-prompt").addEventListener("click", () => setPendingTool("folk-prompt"));
document.getElementById("new-transcription").addEventListener("click", () => setPendingTool("folk-transcription"));
document.getElementById("new-video-chat").addEventListener("click", () => setPendingTool("folk-video-chat"));
@ -3877,7 +3886,7 @@
}
canvasContent.addEventListener("contextmenu", (e) => {
const shapeEl = e.target.closest("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-embed, folk-calendar, folk-map, folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-social-post, folk-rapp, folk-feed, folk-piano, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad");
const shapeEl = e.target.closest("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-embed, folk-calendar, folk-map, folk-image-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-social-post, folk-rapp, folk-feed, folk-piano, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad");
if (!shapeEl || !shapeEl.id) return;
e.preventDefault();
@ -4514,7 +4523,7 @@
// Deselect and exit edit modes immediately
deselectAll();
selectedShapeId = null;
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => {
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block").forEach(el => {
if (el.exitEditMode) el.exitEditMode();
});
@ -4724,7 +4733,7 @@
function sortFeedShapes(key) {
const shapes = [...canvasContent.querySelectorAll(
'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-token, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking'
'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-token, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-zine-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking'
)];
shapes.sort((a, b) => {