From 74a51423492c2b9292068f898cd8a3eaa6b143f8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 21:27:11 -0800 Subject: [PATCH 1/3] feat: Gemini AI integration + zine generator + fix Ollama network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bun.lock | 162 ++++++++ docker-compose.yml | 8 + entrypoint.sh | 63 +-- lib/folk-image-gen.ts | 21 +- lib/folk-prompt.ts | 18 +- lib/folk-zine-gen.ts | 909 ++++++++++++++++++++++++++++++++++++++++++ lib/index.ts | 1 + package.json | 2 + server/index.ts | 363 +++++++++++++++++ website/canvas.html | 23 +- 10 files changed, 1530 insertions(+), 40 deletions(-) create mode 100644 lib/folk-zine-gen.ts diff --git a/bun.lock b/bun.lock index cc3777a..cafcf7a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker-compose.yml b/docker-compose.yml index 7682dc1..ac33c52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index 1ab3d8b..8e8ea1c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 "$@" diff --git a/lib/folk-image-gen.ts b/lib/folk-image-gen.ts index a1b8cd7..90939ed 100644 --- a/lib/folk-image-gen.ts +++ b/lib/folk-image-gen.ts @@ -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 {
+
@@ -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 }), diff --git a/lib/folk-prompt.ts b/lib/folk-prompt.ts index 584f7de..b6574a9 100644 --- a/lib/folk-prompt.ts +++ b/lib/folk-prompt.ts @@ -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 {
@@ -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 diff --git a/lib/folk-zine-gen.ts b/lib/folk-zine-gen.ts new file mode 100644 index 0000000..5c433d7 --- /dev/null +++ b/lib/folk-zine-gen.ts @@ -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` +
+ + 📰 + Zine Generator + +
+ + +
+
+
+ `; + + 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 = ` +
+

Create a MycroZine

+ +
+ + +
+ + ${this.#error ? `
${this.#escapeHtml(this.#error)}
` : ""} +
+ `; + + 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 = ` +
+
+ Generating page ${this.#generatingPage} of ${total}... +
+
+
+ ${this.#pages[this.#generatingPage - 1]?.title || ""} +
+ `; + } + + 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 ``; + }).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 += `
`; + sectionsHtml += `
+ ${this.#escapeHtml(sectionLabel)} +
+ +
+
`; + + if (section.type === "text") { + const textClass = section.id === "headline" ? "headline" : + section.id === "subhead" ? "subhead" : + section.id === "pullquote" ? "pullquote" : ""; + sectionsHtml += `
+ +
`; + } else if (section.type === "image") { + sectionsHtml += `
`; + if (section.imageUrl) { + sectionsHtml += `${this.#escapeHtml(section.imagePrompt || `; + } else { + sectionsHtml += `
${isRegenerating ? '
' : "No image yet — click ↻ to generate"}
`; + } + sectionsHtml += `
`; + } + + // Feedback row for regeneration + sectionsHtml += ``; + + sectionsHtml += `
`; + } + + this.#contentEl.innerHTML = ` + +
+
+ Page ${page.pageNumber} of ${total} — ${this.#escapeHtml(page.title)} + ${page.hashtags?.length ? `${page.hashtags.map(t => this.#escapeHtml(t)).join(" ")}` : ""} +
+ ${sectionsHtml} +
+
+ ${this.#phase === "complete" ? "Complete" : "Editing — click text to edit, ↻ to regenerate sections"} + +
+ ${this.#error ? `
${this.#escapeHtml(this.#error)}
` : ""} + `; + + // ── 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) { + // 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) { + 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) { + 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) { + 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) { + 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 || "")}`; + } + if (s.type === "image" && s.imageUrl) { + return ``; + } + return ""; + }).join("\n"); + + return `
+ ${sectionsHtml} +
${page.hashtags?.join(" ") || ""}
+
`; + }).join("\n"); + + const doc = ` +MycroZine: ${this.#escapeHtml(this.#topic)} + +${pagesHtml}`; + + 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, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index f56c0c8..2ac5d62 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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 diff --git a/package.json b/package.json index 3b7b8f3..bb1cbb3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.ts b/server/index.ts index 7293711..732fe96 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 = { 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 = { + "gemini-flash": "gemini-2.0-flash-exp", + "gemini-pro": "gemini-1.5-pro", +}; +const OLLAMA_MODELS: Record = { + "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 = { + 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 = { + "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 = { + 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 = {}; + + 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({ } })(); +// 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()) diff --git a/website/canvas.html b/website/canvas.html index 61e3c8d..f1b27db 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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 @@
+ @@ -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) => { From e6447970016217f731eb45a4eb0a1ef3e8ab3565 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 21:30:12 -0800 Subject: [PATCH 2/3] feat: Sankey-proportional edges + node satisfaction bars in rFunds diagram Edge widths now reflect actual dollar flow (source rates, overflow excess, spending drain) instead of just allocation percentages. Zero-flow paths render as ghost edges. Edge labels show dollar amounts alongside percentages. Funnel nodes display an inflow satisfaction bar showing how much of their expected inflow is actually arriving. Outcome progress bars enhanced to 8px with dollar labels. Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/components/folk-funds-app.ts | 250 +++++++++++++++----- modules/rfunds/components/funds.css | 7 + 2 files changed, 195 insertions(+), 62 deletions(-) diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index 955de5a..2584f7d 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -648,7 +648,7 @@ class FolkFundsApp extends HTMLElement { private getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") return { w: 200, h: 60 }; - if (n.type === "funnel") return { w: 220, h: 160 }; + if (n.type === "funnel") return { w: 220, h: 180 }; return { w: 200, h: 100 }; // outcome } @@ -916,17 +916,65 @@ class FolkFundsApp extends HTMLElement { document.addEventListener("keydown", this._boundKeyDown); } + // ─── Inflow satisfaction computation ───────────────── + + private computeInflowSatisfaction(): Map { + const result = new Map(); + + for (const n of this.nodes) { + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + const needed = d.inflowRate || 1; + let actual = 0; + // Sum source→funnel allocations + for (const src of this.nodes) { + if (src.type === "source") { + const sd = src.data as SourceNodeData; + for (const alloc of sd.targetAllocations) { + if (alloc.targetId === n.id) actual += sd.flowRate * (alloc.percentage / 100); + } + } + // Sum overflow from parent funnels + if (src.type === "funnel" && src.id !== n.id) { + const fd = src.data as FunnelNodeData; + const excess = Math.max(0, fd.currentValue - fd.maxThreshold); + for (const alloc of fd.overflowAllocations) { + if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); + } + } + // Sum overflow from parent outcomes + if (src.type === "outcome") { + const od = src.data as OutcomeNodeData; + const excess = Math.max(0, od.fundingReceived - od.fundingTarget); + for (const alloc of (od.overflowAllocations || [])) { + if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); + } + } + } + result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) }); + } + if (n.type === "outcome") { + const d = n.data as OutcomeNodeData; + const needed = Math.max(d.fundingTarget, 1); + const actual = d.fundingReceived; + result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) }); + } + } + return result; + } + // ─── Node SVG rendering ─────────────────────────────── private renderAllNodes(): string { - return this.nodes.map((n) => this.renderNodeSvg(n)).join(""); + const satisfaction = this.computeInflowSatisfaction(); + return this.nodes.map((n) => this.renderNodeSvg(n, satisfaction)).join(""); } - private renderNodeSvg(n: FlowNode): string { + private renderNodeSvg(n: FlowNode, satisfaction: Map): string { const sel = this.selectedNodeId === n.id; if (n.type === "source") return this.renderSourceNodeSvg(n, sel); - if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel); - return this.renderOutcomeNodeSvg(n, sel); + if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id)); + return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id)); } private renderSourceNodeSvg(n: FlowNode, selected: boolean): string { @@ -944,9 +992,9 @@ class FolkFundsApp extends HTMLElement { `; } - private renderFunnelNodeSvg(n: FlowNode, selected: boolean): string { + private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as FunnelNodeData; - const x = n.position.x, y = n.position.y, w = 220, h = 160; + const x = n.position.x, y = n.position.y, w = 220, h = 180; const sufficiency = computeSufficiencyState(d); const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; const threshold = d.sufficientThreshold ?? d.maxThreshold; @@ -960,9 +1008,18 @@ class FolkFundsApp extends HTMLElement { : sufficiency === "sufficient" ? "Sufficient" : d.currentValue < d.minThreshold ? "Critical" : "Seeking"; + // Inflow satisfaction bar + const satBarY = 28; + const satBarW = w - 20; + const satRatio = sat ? Math.min(sat.ratio, 1) : 0; + const satOverflow = sat ? sat.ratio > 1 : false; + const satFillW = satBarW * satRatio; + const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; + const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : ""; + // 3-zone background: drain (red), healthy (blue), overflow (amber) - const zoneH = h - 56; // area for zones (below header, above value text) - const zoneY = 32; + const zoneY = 52; + const zoneH = h - 76; const drainPct = d.minThreshold / (d.maxCapacity || 1); const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); const overflowPct = 1 - drainPct - healthyPct; @@ -979,12 +1036,15 @@ class FolkFundsApp extends HTMLElement { return ` ${isSufficient ? `` : ""} + ${this.esc(d.label)} + ${statusLabel} + + + ${satLabel} - ${this.esc(d.label)} - ${statusLabel} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} @@ -992,7 +1052,7 @@ class FolkFundsApp extends HTMLElement { `; } - private renderOutcomeNodeSvg(n: FlowNode, selected: boolean): string { + private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as OutcomeNodeData; const x = n.position.x, y = n.position.y, w = 200, h = 100; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; @@ -1005,18 +1065,24 @@ class FolkFundsApp extends HTMLElement { const phaseW = (w - 20) / d.phases.length; phaseBars = d.phases.map((p, i) => { const unlocked = d.fundingReceived >= p.fundingThreshold; - return ``; + return ``; }).join(""); - phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; + phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; } + // Enhanced progress bar (8px height, green funded portion + grey gap) + const barW = w - 20; + const barY = 34; + const barH = 8; + const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; + return ` ${this.esc(d.label)} - - - ${Math.round(fillPct * 100)}% — $${Math.floor(d.fundingReceived).toLocaleString()} + + + ${Math.round(fillPct * 100)}% — ${dollarLabel} ${phaseBars} ${this.renderPortsSvg(n)} `; @@ -1037,54 +1103,74 @@ class FolkFundsApp extends HTMLElement { // ─── Edge rendering ─────────────────────────────────── + private formatDollar(amount: number): string { + if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1)}M`; + if (amount >= 1_000) return `$${(amount / 1_000).toFixed(1)}k`; + return `$${Math.round(amount)}`; + } + private renderAllEdges(): string { - let html = ""; - // Find max flow rate for Sankey width scaling - const maxFlow = Math.max(1, ...this.nodes.filter((n) => n.type === "source").map((n) => (n.data as SourceNodeData).flowRate)); + // First pass: compute actual dollar flow per edge + interface EdgeInfo { + fromNode: FlowNode; + toNode: FlowNode; + fromPort: PortKind; + color: string; + flowAmount: number; + pct: number; + dashed: boolean; + fromId: string; + toId: string; + edgeType: string; + } + const edges: EdgeInfo[] = []; for (const n of this.nodes) { if (n.type === "source") { const d = n.data as SourceNodeData; - const from = this.getPortPosition(n, "outflow"); for (const alloc of d.targetAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 12); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#10b981", strokeW, false, - alloc.percentage, n.id, alloc.targetId, "source", - ); + const flowAmount = d.flowRate * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "outflow", + color: alloc.color || "#10b981", flowAmount, + pct: alloc.percentage, dashed: false, + fromId: n.id, toId: alloc.targetId, edgeType: "source", + }); } } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - // Overflow edges — from overflow port + // Overflow edges — actual excess flow for (const alloc of d.overflowAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const from = this.getPortPosition(n, "overflow"); - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 10); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#f59e0b", strokeW, true, - alloc.percentage, n.id, alloc.targetId, "overflow", - ); + const excess = Math.max(0, d.currentValue - d.maxThreshold); + const flowAmount = excess * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "overflow", + color: alloc.color || "#f59e0b", flowAmount, + pct: alloc.percentage, dashed: true, + fromId: n.id, toId: alloc.targetId, edgeType: "overflow", + }); } - // Spending edges — from spending port + // Spending edges — rate-based drain for (const alloc of d.spendingAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const from = this.getPortPosition(n, "spending"); - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#8b5cf6", strokeW, false, - alloc.percentage, n.id, alloc.targetId, "spending", - ); + let rateMultiplier: number; + if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; + else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; + else rateMultiplier = 0.1; + const drain = d.inflowRate * rateMultiplier; + const flowAmount = drain * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "spending", + color: alloc.color || "#8b5cf6", flowAmount, + pct: alloc.percentage, dashed: false, + fromId: n.id, toId: alloc.targetId, edgeType: "spending", + }); } } // Outcome overflow edges @@ -1094,44 +1180,84 @@ class FolkFundsApp extends HTMLElement { for (const alloc of allocs) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const from = this.getPortPosition(n, "overflow"); - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#f59e0b", strokeW, true, - alloc.percentage, n.id, alloc.targetId, "overflow", - ); + const excess = Math.max(0, d.fundingReceived - d.fundingTarget); + const flowAmount = excess * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "overflow", + color: alloc.color || "#f59e0b", flowAmount, + pct: alloc.percentage, dashed: true, + fromId: n.id, toId: alloc.targetId, edgeType: "overflow", + }); } } } + + // Find max flow amount for width normalization + const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount)); + + // Second pass: render edges with normalized widths + let html = ""; + for (const e of edges) { + const from = this.getPortPosition(e.fromNode, e.fromPort); + const to = this.getPortPosition(e.toNode, "inflow"); + const isGhost = e.flowAmount === 0; + const strokeW = isGhost ? 1 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14); + const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`; + html += this.renderEdgePath( + from.x, from.y, to.x, to.y, + e.color, strokeW, e.dashed, isGhost, + label, e.fromId, e.toId, e.edgeType, + ); + } return html; } private renderEdgePath( x1: number, y1: number, x2: number, y2: number, - color: string, strokeW: number, dashed: boolean, - pct: number, fromId: string, toId: string, edgeType: string, + color: string, strokeW: number, dashed: boolean, ghost: boolean, + label: string, fromId: string, toId: string, edgeType: string, ): string { const cy1 = y1 + (y2 - y1) * 0.4; const cy2 = y1 + (y2 - y1) * 0.6; const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + + if (ghost) { + return ` + + + + ${label} + + + + + + + + + + + `; + } + const animClass = dashed ? "edge-path-overflow" : "edge-path-animated"; + // Wider label box to fit dollar amounts + const labelW = Math.max(68, label.length * 7 + 36); + const halfW = labelW / 2; return ` - - ${pct}% + + ${label} - - + + - - + + + + `; diff --git a/modules/rfunds/components/funds.css b/modules/rfunds/components/funds.css index 2617e5c..1c6db8d 100644 --- a/modules/rfunds/components/funds.css +++ b/modules/rfunds/components/funds.css @@ -334,6 +334,13 @@ .edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); } .edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; } +/* Ghost edge (zero-flow potential paths) */ +.edge-ghost { pointer-events: none; } + +/* Satisfaction bar (inflow bar on funnels & outcomes) */ +.satisfaction-bar-bg { opacity: 0.3; } +.satisfaction-bar-fill { transition: width 0.3s ease; } + /* ── Node detail modals ──────────────────────────────── */ .funds-modal-backdrop { position: fixed; inset: 0; z-index: 50; From e3c4d74b1ad58621402d27bec067fc2030ef4b9e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 21:43:42 -0800 Subject: [PATCH 3/3] fix: update Gemini model names from deprecated -exp to current versions gemini-2.0-flash-exp was removed from the API. Updated to: - gemini-2.5-flash for text generation - gemini-2.5-pro for outline/reasoning - gemini-2.5-flash-image for image generation with responseModalities Co-Authored-By: Claude Opus 4.6 --- lib/folk-prompt.ts | 4 ++-- server/index.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/folk-prompt.ts b/lib/folk-prompt.ts index b6574a9..f641642 100644 --- a/lib/folk-prompt.ts +++ b/lib/folk-prompt.ts @@ -281,8 +281,8 @@ export class FolkPrompt extends FolkShape {