├── .gitignore ├── README.md ├── api-sniffer.ts ├── build-for-website-and-copy.sh ├── bun.lock ├── index.html ├── index.tsx ├── logit-loom.ts ├── media ├── hero.png ├── node-chips.png └── utf8-repair.png ├── openai.ts ├── package.json ├── save-load.ts ├── tree-store.ts ├── tsconfig.json ├── vendor-openai.sh └── vendored └── openai.js /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IntelliJ based IDEs 31 | .idea 32 | 33 | # Finder (MacOS) folder config 34 | .DS_Store 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # logitloom 2 | 3 | `logitloom` is a tool for exploring token trajectory trees (aka looming) on instruct and base models. 4 | 5 | ![Screenshot of logitloom](media/hero.png) 6 | 7 | ## Getting started 8 | 9 | Go to https://vgel.me/logitloom to use the deployed version of the tool. 10 | 11 | ### Using a chat model 12 | 13 | You will need a chat API that supports both assistant prefill (for the prefill setting and for expanding non-chosen branches), along with logprobs. I recommend deepseek-v3 for this. (NOT r1, which doesn't support logprobs via the Deepseek API.) 14 | 15 | * **Base URL:** `https://api.deepseek.com/beta` 16 | * **API Key:** Get one from https://platform.deepseek.com/api_keys. API keys are only stored and used locally, there's no backend. 17 | * **Model:** `deepseek-chat` 18 | * **Type:** `chat` 19 | 20 | (You can save this as a preset to easily switch back to later using Edit Presets.) 21 | 22 | You can now fill in a prompt, and optionally an assistant prefill, and click run to start expanding the tree. 23 | 24 | ### Using a base model 25 | 26 | The best base model host currently is Hyperbolic's 405-base. I recommend using it directly, instead of via OpenRouter, which can introduce some issues. You will need a completions API that supports logprobs. (Most do.) 27 | 28 | * **Base URL:** `https://api.hyperbolic.xyz/v1` 29 | * **API Key:** Get one from https://app.hyperbolic.xyz/settings. API keys are only stored and used locally, there's no backend. 30 | * **Model:** `meta-llama/Meta-Llama-3.1-405B` 31 | * **Type:** `base` 32 | 33 | (You can save this as a preset to easily switch back to later using Edit Presets.) 34 | 35 | You can now fill in either a prompt or prefill, and click run to start expanding the tree. (Prompt and prefill are simply concatenated for base models, so use either one.) 36 | 37 | ## Features 38 | 39 | * "Run" will begin building a new tree (overwriting your current one) using the given expansion settings. 40 | * **Depth:** How deep to expand the tree, in tokens. 41 | * **Max children:** How many child options to consider for each node. This may be limited by the number of logprobs the API returns. 42 | * **Top P:** Further limits **Max children** by only considering children up to a probability threshold. If you don't want to use this, set it to 100. Models with more branch diversity, such as base models, will need a lower Top P to limit branching. 43 | * Tree nodes have several pieces of information, along with two action buttons. ![Tree node screenshot](media/node-chips.png) 44 | * **Token:** The token generated at this position. (Spaces and newlines are rendered as visible characters.) 45 | * **Probability / Logprob:** The token chance and raw logprob. 46 | * **Add to prefill:** Appends this token and the tokens leading up to it (highlighted in green) to the prefill, so that "run" will generate from here in the future. (You can also edit the prefill to tweak it before running the tree again.) 47 | * **Expand from here:** Expands the tree in-place from this node, using the same settings as "run". 48 | * UTF-8 repair will attempt to render UTF-8 characters split over multiple tokens. ![UTF-8 repair screenshot](media/utf8-repair.png) 49 | * Currently, logitloom suffers from an issue where escaped UTF-8 is passed back into the model, causing it to generate strange escape sequences. This is difficult to fix due to [tokenization continuing to suck in new and profound ways](https://x.com/voooooogel/status/1920032451197317430). 50 | 51 | ## License 52 | 53 | Currently unlicensed. TODO. 54 | 55 | ## Development 56 | 57 | Uses [Bun](https://bun.sh) for bundling and serving in development. 58 | 59 | * Serve dev: `bun --hot index.html` 60 | * Due to a Bun bug, you will need a recent browser, such as Firefox 138. https://github.com/oven-sh/bun/pull/19469 61 | * Bundle (if you aren't thebes, you don't need this): `./build-for-website-and-copy.sh` 62 | 63 | We currently vendor the OpenAI library due to some issues bundling it for browser with Bun. See `vendor-openai.sh`. -------------------------------------------------------------------------------- /api-sniffer.ts: -------------------------------------------------------------------------------- 1 | export interface ApiInfo { 2 | provider: 3 | | "openai" 4 | | "anthropic" 5 | | "deepseek" 6 | | "openrouter" 7 | | "hyperbolic" 8 | | "vllm" 9 | | "kobold-cpp" 10 | | "llama-server" 11 | | "nous" 12 | | "unknown"; 13 | supportsLogprobs: "yes" | "no" | "unknown"; 14 | supportsPrefill: "yes" | "no" | "unknown"; 15 | prefillStyle?: { kind: "trailing" } | { kind: "flags"; flags: Record; target: "body" | "message" }; 16 | needsTemperature?: number; 17 | onlySupportsModels?: string[]; 18 | extraWarning?: string; 19 | } 20 | 21 | const UNKNOWN_API: ApiInfo = { 22 | provider: "unknown", 23 | supportsLogprobs: "unknown", 24 | supportsPrefill: "unknown", 25 | }; 26 | 27 | export async function sniffApi(baseUrl: string, apiKey: string): Promise { 28 | baseUrl = baseUrl.replace(/\/+$/, ""); 29 | 30 | // walk up baseUrl in case /models is hosted on a higher path (e.g. deepseek has api.deepseek.com/models but not /beta/models) 31 | for (let i = 0; i < 3; i++) { 32 | try { 33 | const info = await _sniffApi(baseUrl, apiKey); 34 | if (info.provider !== "unknown") { 35 | return info; 36 | } 37 | } catch (e) { 38 | console.log(`/models error for ${baseUrl}:`, e); 39 | } 40 | baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf("/")); 41 | if (!baseUrl.includes("://")) { 42 | break; 43 | } 44 | } 45 | 46 | return UNKNOWN_API; 47 | } 48 | 49 | async function _sniffApi(baseUrl: string, apiKey: string): Promise { 50 | // we could just check baseUrl here, but it might be proxied. 51 | const response = await fetch(`${baseUrl}/models`, { 52 | headers: { 53 | Authorization: `Bearer ${apiKey}`, 54 | "x-api-key": apiKey, 55 | "anthropic-dangerous-direct-browser-access": "true", 56 | }, 57 | redirect: "follow", 58 | }); 59 | console.log(`/models response for ${baseUrl}:`, response); 60 | 61 | if (response.status === 200) { 62 | let json; 63 | try { 64 | json = await response.json(); 65 | } catch (e) { 66 | console.error(`Getting /models from ${baseUrl}: 200, but not JSON.`); 67 | return UNKNOWN_API; 68 | } 69 | 70 | const models = (json.data ?? []).map((m: any) => m.id); 71 | const owners = (json.data ?? []).map((m: any) => m.owned_by); 72 | if (owners.includes("koboldcpp")) { 73 | return { 74 | provider: "kobold-cpp", 75 | supportsLogprobs: "yes", 76 | supportsPrefill: "unknown", 77 | prefillStyle: { kind: "trailing" }, 78 | }; 79 | } else if (owners.includes("llamacpp")) { 80 | return { 81 | provider: "llama-server", 82 | supportsLogprobs: "yes", 83 | supportsPrefill: "yes", 84 | prefillStyle: { kind: "trailing" }, 85 | }; 86 | } else if (owners.includes("vllm")) { 87 | return { 88 | provider: "vllm", 89 | supportsLogprobs: "yes", 90 | supportsPrefill: "yes", 91 | prefillStyle: { 92 | kind: "flags", 93 | flags: { continue_final_message: true, add_generation_prompt: false }, 94 | target: "body", 95 | }, 96 | extraWarning: 97 | "Relaunch VLLM with VLLM_USE_V1=0 if you notice tokens like 'Ġhello'. See vllm-project/vllm#16838", 98 | }; 99 | } else if (models.includes("openrouter/auto")) { 100 | return { 101 | provider: "openrouter", 102 | supportsLogprobs: "unknown", 103 | supportsPrefill: "unknown", 104 | prefillStyle: { kind: "trailing" }, 105 | }; 106 | } else if (owners.includes("Hyperbolic")) { 107 | return { 108 | provider: "hyperbolic", 109 | supportsLogprobs: "unknown", // yes for some models, not for others? 110 | supportsPrefill: "unknown", // same 111 | prefillStyle: { kind: "trailing" }, 112 | }; 113 | } else if (models.includes("chatgpt-4o-latest")) { 114 | return { 115 | provider: "openai", 116 | supportsLogprobs: "yes", 117 | supportsPrefill: "no", 118 | }; 119 | } else if (models.includes("deepseek-chat")) { 120 | return { 121 | provider: "deepseek", 122 | supportsLogprobs: "yes", 123 | supportsPrefill: "yes", 124 | prefillStyle: { kind: "flags", flags: { prefix: true }, target: "message" }, 125 | needsTemperature: 1.0, 126 | onlySupportsModels: ["deepseek-chat"], 127 | }; 128 | } else if (models.includes("DeepHermes-3-Mistral-24B-Preview")) { 129 | return { 130 | provider: "nous", 131 | supportsLogprobs: "yes", 132 | supportsPrefill: "yes", 133 | prefillStyle: { 134 | kind: "flags", 135 | flags: { continue_final_message: true, add_generation_prompt: false }, 136 | target: "body", 137 | }, 138 | }; 139 | } 140 | } else { 141 | const error = await response.text(); 142 | if (error.includes("anthropic-version")) { 143 | return { 144 | provider: "anthropic", 145 | supportsLogprobs: "no", 146 | supportsPrefill: "yes", 147 | prefillStyle: { kind: "trailing" }, 148 | }; 149 | } 150 | } 151 | 152 | return UNKNOWN_API; 153 | } 154 | -------------------------------------------------------------------------------- /build-for-website-and-copy.sh: -------------------------------------------------------------------------------- 1 | # build the site to dist/ for vgel.me/logitloom 2 | 3 | DEPLOY_PATH=/home/vogel/prog/blog-vgel/static/logitloom 4 | FIND='' 5 | REPLACE=' 6 | ' 7 | 8 | bun build --outdir=dist index.html 9 | 10 | mv dist/index.html dist/index.original.html 11 | rg \ 12 | --passthru \ 13 | --fixed-strings "$FIND" \ 14 | --replace "$REPLACE" \ 15 | dist/index.original.html \ 16 | > dist/index.html 17 | 18 | if [ -z "$DEPLOY_PATH" ]; then 19 | echo "DEPLOY_PATH not set!" 20 | exit 1 21 | fi 22 | 23 | # trailing slash on dist/ important! copy contents instead of folder 24 | rsync -av --delete dist/ "$DEPLOY_PATH" 25 | 26 | echo "Copied, remember to run make build && make deploy in the blog-vgel directory." 27 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "logittree", 6 | "dependencies": { 7 | "d3": "^7.9.0", 8 | "react": "^19.1.0", 9 | "react-dom": "^19.1.0", 10 | "use-local-storage-state": "^19.5.0", 11 | "uuid": "^11.1.0", 12 | }, 13 | "devDependencies": { 14 | "@types/bun": "latest", 15 | "@types/d3": "^7.4.3", 16 | "@types/react": "^19.1.2", 17 | "@types/react-dom": "^19.1.3", 18 | "openai": "^4.97.0", 19 | }, 20 | "peerDependencies": { 21 | "typescript": "^5", 22 | }, 23 | }, 24 | }, 25 | "packages": { 26 | "@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="], 27 | 28 | "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], 29 | 30 | "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], 31 | 32 | "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], 33 | 34 | "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], 35 | 36 | "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], 37 | 38 | "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], 39 | 40 | "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], 41 | 42 | "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], 43 | 44 | "@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="], 45 | 46 | "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], 47 | 48 | "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], 49 | 50 | "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], 51 | 52 | "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], 53 | 54 | "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], 55 | 56 | "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], 57 | 58 | "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], 59 | 60 | "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], 61 | 62 | "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], 63 | 64 | "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], 65 | 66 | "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], 67 | 68 | "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], 69 | 70 | "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], 71 | 72 | "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], 73 | 74 | "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], 75 | 76 | "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], 77 | 78 | "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], 79 | 80 | "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], 81 | 82 | "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], 83 | 84 | "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], 85 | 86 | "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], 87 | 88 | "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], 89 | 90 | "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], 91 | 92 | "@types/node": ["@types/node@18.19.87", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-OIAAu6ypnVZHmsHCeJ+7CCSub38QNBS9uceMQeg7K5Ur0Jr+wG9wEOEvvMbhp09pxD5czIUy/jND7s7Tb6Nw7A=="], 93 | 94 | "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], 95 | 96 | "@types/react": ["@types/react@19.1.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw=="], 97 | 98 | "@types/react-dom": ["@types/react-dom@19.1.3", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg=="], 99 | 100 | "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 101 | 102 | "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], 103 | 104 | "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 105 | 106 | "bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="], 107 | 108 | "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 109 | 110 | "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], 111 | 112 | "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], 113 | 114 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 115 | 116 | "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], 117 | 118 | "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], 119 | 120 | "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], 121 | 122 | "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], 123 | 124 | "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], 125 | 126 | "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], 127 | 128 | "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], 129 | 130 | "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], 131 | 132 | "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], 133 | 134 | "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], 135 | 136 | "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], 137 | 138 | "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], 139 | 140 | "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], 141 | 142 | "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], 143 | 144 | "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], 145 | 146 | "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], 147 | 148 | "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], 149 | 150 | "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], 151 | 152 | "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], 153 | 154 | "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], 155 | 156 | "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], 157 | 158 | "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], 159 | 160 | "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], 161 | 162 | "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], 163 | 164 | "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], 165 | 166 | "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], 167 | 168 | "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], 169 | 170 | "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], 171 | 172 | "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], 173 | 174 | "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], 175 | 176 | "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], 177 | 178 | "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 179 | 180 | "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], 181 | 182 | "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 183 | 184 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 185 | 186 | "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 187 | 188 | "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 189 | 190 | "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], 191 | 192 | "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], 193 | 194 | "form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="], 195 | 196 | "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], 197 | 198 | "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], 199 | 200 | "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 201 | 202 | "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 203 | 204 | "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 205 | 206 | "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 207 | 208 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 209 | 210 | "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], 211 | 212 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 213 | 214 | "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], 215 | 216 | "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], 217 | 218 | "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], 219 | 220 | "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 221 | 222 | "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 223 | 224 | "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 225 | 226 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 227 | 228 | "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], 229 | 230 | "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], 231 | 232 | "openai": ["openai@4.97.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-LRoiy0zvEf819ZUEJhgfV8PfsE8G5WpQi4AwA1uCV8SKvvtXQkoWUFkepD6plqyJQRghy2+AEPQ07FrJFKHZ9Q=="], 233 | 234 | "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], 235 | 236 | "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], 237 | 238 | "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], 239 | 240 | "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 241 | 242 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 243 | 244 | "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], 245 | 246 | "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], 247 | 248 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 249 | 250 | "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 251 | 252 | "use-local-storage-state": ["use-local-storage-state@19.5.0", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-sUJAyFvsmqMpBhdwaRr7GTKkkoxb6PWeNVvpBDrLuwQF1PpbJRKIbOYeLLeqJI7B3wdfFlLLCBbmOdopiSTBOw=="], 253 | 254 | "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], 255 | 256 | "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], 257 | 258 | "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], 259 | 260 | "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 261 | 262 | "@types/node-fetch/@types/node": ["@types/node@22.15.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw=="], 263 | 264 | "bun-types/@types/node": ["@types/node@22.15.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw=="], 265 | 266 | "@types/node-fetch/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 267 | 268 | "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | logitloom 6 | 8 | 9 | 285 | 286 | 287 | 288 |
289 | 290 | 291 | -------------------------------------------------------------------------------- /index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState, type JSX } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import useLocalStorageState from "use-local-storage-state"; 4 | import * as uuid from "uuid"; 5 | 6 | import { type Token } from "./logit-loom"; 7 | import * as TreeStore from "./tree-store"; 8 | import type { ApiInfo } from "./api-sniffer"; 9 | 10 | const possibleModelTypes = ["chat", "base"] as const; 11 | type ModelType = (typeof possibleModelTypes)[number]; 12 | function coerceToModelType(maybeType: string | undefined): ModelType { 13 | const m = maybeType?.toLowerCase() ?? "chat"; 14 | if (([...possibleModelTypes] as string[]).includes(m)) { 15 | return m as ModelType; 16 | } 17 | return "chat"; 18 | } 19 | interface ApiPreset { 20 | id: string; 21 | presetName: string; 22 | baseUrl: string; 23 | apiKey: string; 24 | modelName: string; 25 | modelType: ModelType; 26 | } 27 | 28 | function App(): JSX.Element { 29 | const [darkMode, setDarkMode] = useLocalStorageState("darkMode", { defaultValue: false }); 30 | 31 | const [baseUrl, setBaseUrl] = useLocalStorageState("baseUrl"); 32 | const [apiKey, setApiKey] = useLocalStorageState("apiKey"); 33 | const [modelName, setModelName] = useLocalStorageState("modelName"); 34 | const [_modelType, setModelType] = useLocalStorageState<"chat" | "base">("modelType", { defaultValue: "chat" }); 35 | const modelType = coerceToModelType(_modelType); 36 | 37 | const [apiPresets, setApiPresets] = useLocalStorageState("apiPresets"); 38 | const [currentPresetId, setCurrentPresetId] = useLocalStorageState("currentPreset", { 39 | defaultValue: apiPresets?.[0]?.id ?? "", 40 | }); 41 | 42 | const [systemPrompt, setSystemPrompt] = useLocalStorageState("lastSystemPrompt"); 43 | const [prompt, setPrompt] = useLocalStorageState("lastPrompt"); 44 | const [prefill, setPrefill] = useLocalStorageState("lastPrefill"); 45 | 46 | const [depth, setDepth] = useLocalStorageState("depth", { defaultValue: 5 }); 47 | const [width, setWidth] = useLocalStorageState("maxWidth", { defaultValue: 3 }); 48 | const [coverProb, setCoverProb] = useLocalStorageState("coverProb", { defaultValue: 0.8 }); 49 | 50 | const store = TreeStore.useTreeStore(); 51 | const [foldedNodeIds, setFoldedNodeIds] = useLocalStorageState("foldedNodes", { defaultValue: [] }); 52 | useEffect(() => { 53 | TreeStore.loadTreeFromLocalStorage(); 54 | }, []); 55 | 56 | return ( 57 | <> 58 |
59 | {" "} 60 | {store.value.kind === "error" && !store.running &&
{store.value.error.toString()}
} 61 |
62 | 63 | 64 | {" "} 65 | {" "} 66 | {" "} 67 | ({ id: v, text: v }))} 71 | value={modelType} 72 | onChange={(type) => { 73 | setModelType(coerceToModelType(type)); 74 | }} 75 | /> 76 | setApiPresets(presets)} 85 | pickPreset={(preset) => { 86 | setBaseUrl(preset.baseUrl); 87 | setApiKey(preset.apiKey); 88 | setModelName(preset.modelName); 89 | setModelType(preset.modelType); 90 | setCurrentPresetId(preset.id); 91 | }} 92 | /> 93 | {!!baseUrl && store.baseUrlApiInfoCache[baseUrl] != null && ( 94 | 95 | )} 96 | 97 |
98 | 99 | {modelType === "chat" && }{" "} 100 | {" "} 101 | 102 | 103 |
104 | 105 | {" "} 114 | {" "} 123 | setCoverProb(v / 100)} 131 | />{" "} 132 | {" "} 154 | 157 | 158 | { 176 | // TODO: this isn't a great idea right now bc we don't want to serialize the apiKey / baseUrl, which might be private 177 | // maybe we should instead pop something up saying that the tree was generated with a different model than currently selected 178 | // setModelName(modelName); 179 | // setModelType(modelSettings.kind); 180 | if (modelSettings.kind === "chat") { 181 | setSystemPrompt(modelSettings.systemPrompt); 182 | } 183 | setPrompt(modelSettings.prompt); 184 | setPrefill(modelSettings.prefill); 185 | }} 186 | /> 187 | 188 |
189 |
190 | { 195 | const newPrefill = TreeStore.getTokenAndPrefix(store, id); 196 | if (newPrefill !== null) { 197 | setPrefill((prefill ?? "") + newPrefill); 198 | } 199 | }} 200 | expandDisabled={!baseUrl || !apiKey || !modelName || store.running} 201 | onClickExpandFromHere={(id) => { 202 | if (!baseUrl || !apiKey || !modelName || store.running) { 203 | return; 204 | } 205 | TreeStore.run(store, { 206 | baseUrl, 207 | apiKey, 208 | modelName, 209 | modelType, 210 | systemPrompt, 211 | prompt, 212 | prefill, 213 | depth, 214 | maxWidth: width, 215 | coverProb, 216 | fromNodeId: id, 217 | }); 218 | }} 219 | /> 220 |
221 | 222 | ); 223 | } 224 | 225 | // Tree UI 226 | 227 | function Tree(props: { 228 | roots: Token[]; 229 | foldedNodeIds: string[]; 230 | setFoldedNodeIds: (ids: string[]) => void; 231 | onClickAddPrefill: (id: string) => void; 232 | expandDisabled: boolean; 233 | onClickExpandFromHere: (id: string) => void; 234 | }): JSX.Element { 235 | return ( 236 |
    237 | {props.roots.map((c) => ( 238 | 249 | ))} 250 |
251 | ); 252 | } 253 | 254 | const TreeNode = React.memo(function TreeNode({ 255 | node, 256 | parents, 257 | siblings, 258 | foldedNodeIds, 259 | setFoldedNodeIds, 260 | parentHasShortDownLine, 261 | onClickAddPrefill, 262 | expandDisabled, 263 | onClickExpandFromHere, 264 | }: { 265 | node: Token; 266 | parents: Token[]; 267 | siblings: Token[]; 268 | foldedNodeIds: string[]; 269 | setFoldedNodeIds: (ids: string[]) => void; 270 | parentHasShortDownLine?: boolean; 271 | onClickAddPrefill: (id: string) => void; 272 | expandDisabled: boolean; 273 | onClickExpandFromHere: (id: string) => void; 274 | }): JSX.Element { 275 | // has a down line if any children 276 | const hasDownLine = node.children.length > 0; 277 | // ...but it's short if it's an only child or the last child 278 | const hasShortDownLine = hasDownLine && (siblings.length === 1 || siblings.at(-1) === node); 279 | // has a left line if it has a parent, and either 280 | // 1. parent doesn't have a short down line 281 | // 2. parent does have a short down line, but this is the first child 282 | const hasLeftLine = parents.length === 0 ? false : parentHasShortDownLine ? siblings[0] === node : true; 283 | 284 | const recoveredEmoji = tryRecoverBrokenEmoji([...parents, node]); 285 | 286 | const isFolded = foldedNodeIds.includes(node.id); 287 | 288 | const text = node.text.replaceAll(" ", "␣").replaceAll("\n", "↵"); 289 | return ( 290 |
  • 298 |
    299 | {node.children.length ? {text} : text}{" "} 300 | {recoveredEmoji ? utf8: {recoveredEmoji.trim()} : ""}{" "} 301 | ({(node.prob * 100).toFixed(2)}%){" "} 302 | 303 | [{node.logprob.toFixed(4)}]{" "} 304 | {node.branchFinished != null && node.children.length === 0 && `<|${node.branchFinished}|>`} 305 | {" "} 306 | {node.children.length > 0 && ( 307 | 318 | )}{" "} 319 | {" "} 322 | {" "} 330 |
    331 | {!!node.children.length && !isFolded && ( 332 |
      333 | {node.children.map((c) => ( 334 | 346 | ))} 347 |
    348 | )} 349 |
  • 350 | ); 351 | }); 352 | 353 | // Settings components 354 | 355 | function DarkModeToggle(props: { darkMode: boolean; setDarkMode: (darkMode: boolean) => void }): JSX.Element { 356 | useEffect(() => { 357 | if (props.darkMode && !document.body.classList.contains("dark-mode")) { 358 | document.body.classList.add("dark-mode"); 359 | } 360 | }, []); 361 | 362 | return ( 363 | 377 | ); 378 | } 379 | 380 | function Settings(props: { children?: React.ReactNode | undefined }): JSX.Element { 381 | return
    {props.children}
    ; 382 | } 383 | 384 | function SettingsSpacer(): JSX.Element { 385 | return
    ; 386 | } 387 | 388 | function TextSetting(props: { 389 | label: string; 390 | type: "text" | "password"; 391 | value: string | undefined; 392 | onChange: (value: string) => void; 393 | }): JSX.Element { 394 | return ( 395 | 398 | ); 399 | } 400 | 401 | function TextSettingInput(props: { 402 | type: "text" | "password"; 403 | value: string | undefined; 404 | onChange: (value: string) => void; 405 | }): JSX.Element { 406 | return ( 407 | props.onChange(e.target.value)} 415 | /> 416 | ); 417 | } 418 | 419 | function PromptSetting(props: { 420 | label: string; 421 | value: string | undefined; 422 | onChange: (value: string) => void; 423 | }): JSX.Element { 424 | return ( 425 | 429 | ); 430 | } 431 | 432 | const Tooltip = (props: { tooltip: string }) => (?); 433 | 434 | function NumberSetting(props: { 435 | label: string; 436 | tooltip: string; 437 | min: number; 438 | max: number; 439 | step: number; 440 | value: number; 441 | onChange: (value: number) => void; 442 | }): JSX.Element { 443 | return ( 444 | 458 | ); 459 | } 460 | 461 | function DropdownSetting(props: { 462 | label: string; 463 | tooltip: string; 464 | options: Array<{ id: string; text: string }>; 465 | value: string; 466 | onChange: (value: string) => void; 467 | }): JSX.Element { 468 | return ( 469 | 476 | ); 477 | } 478 | 479 | function DropdownSettingSelect(props: { 480 | options: Array<{ id: string; text: string }>; 481 | value: string; 482 | onChange: (value: string) => void; 483 | }): JSX.Element { 484 | return ( 485 | 492 | ); 493 | } 494 | 495 | // "Edit presets" dialog components 496 | 497 | function EditPresetsButtonDialog(props: { 498 | currentValues: Partial>; 499 | currentPresetId: string; 500 | presets: ApiPreset[] | undefined; 501 | setPresets: (presets: ApiPreset[]) => void; 502 | pickPreset: (preset: ApiPreset) => void; 503 | }): JSX.Element { 504 | const modal = useRef(null); 505 | const presets = props.presets ?? []; 506 | 507 | return ( 508 | <> 509 | {presets.length > 0 && ( 510 | <> 511 | ({ id: p.id, text: p.presetName }))} 515 | value={props.currentPresetId} 516 | onChange={(presetId) => { 517 | const preset = presets.find((p) => p.id === presetId); 518 | if (preset != null) { 519 | props.pickPreset(preset); 520 | } 521 | }} 522 | />{" "} 523 | 524 | )} 525 | 532 | 533 | { 537 | modal.current?.close(); 538 | }} 539 | > 540 |
    541 | Edit API Presets 542 | 549 |
    550 |
    551 |
    552 | 569 | 586 |
    587 |
    588 | Preset Name 589 | Base URL 590 | API Key 591 | Model 592 | Type 593 | 594 | {presets.map((preset) => ( 595 | { 599 | props.setPresets(presets.map((p) => (p.id === newPreset.id ? newPreset : p))); 600 | }} 601 | deletePreset={() => { 602 | props.setPresets(presets.filter((p) => p.id !== preset.id)); 603 | }} 604 | /> 605 | ))} 606 |
    607 |
    608 | 609 | ); 610 | } 611 | 612 | function EditPresetsDialogRow(props: { 613 | preset: ApiPreset; 614 | onChange: (newPreset: ApiPreset) => void; 615 | deletePreset: () => void; 616 | }): JSX.Element { 617 | const { presetName, baseUrl, apiKey, modelName, modelType } = props.preset; 618 | return ( 619 | <> 620 | { 624 | props.onChange({ ...props.preset, presetName: newPresetName }); 625 | }} 626 | /> 627 | { 631 | props.onChange({ ...props.preset, baseUrl: newBaseUrl }); 632 | }} 633 | /> 634 | { 638 | props.onChange({ ...props.preset, apiKey: newApiKey }); 639 | }} 640 | /> 641 | { 645 | props.onChange({ ...props.preset, modelName: newModelName }); 646 | }} 647 | /> 648 | ({ id: v, text: v }))} 650 | value={modelType} 651 | onChange={(newModelType) => { 652 | props.onChange({ ...props.preset, modelType: coerceToModelType(newModelType) }); 653 | }} 654 | /> 655 | 662 | 663 | ); 664 | } 665 | 666 | // Api warning text (detected provider etc) 667 | 668 | function ShowAPIWarning({ 669 | apiInfo, 670 | modelName, 671 | modelType, 672 | }: { 673 | apiInfo: ApiInfo; 674 | modelName?: string; 675 | modelType: "chat" | "base"; 676 | }): JSX.Element { 677 | const modelIsSupported = 678 | apiInfo.onlySupportsModels == null || !modelName || apiInfo.onlySupportsModels.includes(modelName); 679 | 680 | const warnings: string[] = []; 681 | if (modelType === "chat" && apiInfo.supportsPrefill !== "yes") { 682 | warnings.push( 683 | apiInfo.supportsPrefill === "no" 684 | ? "This provider doesn't support assistant prefill, which is required by LogitLoom" 685 | : "This provider may not support assistant prefill for some/all models (tagged 'unknown')" 686 | ); 687 | } 688 | if (apiInfo.supportsLogprobs !== "yes") { 689 | warnings.push( 690 | apiInfo.supportsLogprobs === "no" 691 | ? "This provider doesn't support logprobs, which are required by LogitLoom" 692 | : "This provider may not support logprobs for some/all models (tagged 'unknown')" 693 | ); 694 | } 695 | if (!modelIsSupported) { 696 | warnings.push(`The model ${modelName} is not tagged as a supported model for this provider.`); 697 | } 698 | if (apiInfo.extraWarning != null) { 699 | warnings.push(apiInfo.extraWarning); 700 | } 701 | 702 | const sev = apiInfo.supportsPrefill === "no" || apiInfo.supportsLogprobs === "no" ? "❌ Critical" : "⚠️ Warning"; 703 | 704 | return warnings.length === 0 ? ( 705 |
    706 | ) : ( 707 |
    708 | {sev}:{" "} 709 | 710 | Detected provider {apiInfo.provider}.{" "} 711 | {warnings.length === 1 ? warnings[0] + "." : warnings.join(". ")} 712 | 713 |
    714 | ); 715 | } 716 | 717 | // Export / Load / Clear Tree buttons 718 | 719 | function TreeSaveLoadClearButtons(props: { 720 | store: TreeStore.State; 721 | modelName: string; 722 | modelSettings: TreeStore.SerializedModelSettings; 723 | importSettings: (modelName: string, modelSettings: TreeStore.SerializedModelSettings) => void; 724 | }): JSX.Element { 725 | return ( 726 |
    727 | Tree: 728 | 734 | 735 | 736 |
    737 | ); 738 | } 739 | 740 | function SaveButton(props: { 741 | store: TreeStore.State; 742 | disabled: boolean; 743 | modelName: string; 744 | modelSettings: TreeStore.SerializedModelSettings; 745 | }): JSX.Element { 746 | return ( 747 | 755 | ); 756 | } 757 | 758 | function LoadButton(props: { 759 | disabled: boolean; 760 | importSettings: (modelName: string, modelSettings: TreeStore.SerializedModelSettings) => void; 761 | }): JSX.Element { 762 | return ( 763 | 771 | ); 772 | } 773 | 774 | function ClearButton(props: { disabled: boolean }): JSX.Element { 775 | const [confirming, setConfirming] = useState(false); 776 | return ( 777 | 794 | ); 795 | } 796 | 797 | // Broken UTF-8 chip helpers 798 | 799 | /** 800 | * Try to fix broken emoji sequences, e.g. { text: "\\x20\\xf0\\x9f\\x8c", ... }, 801 | * which may be spread over multiple tokens. 802 | **/ 803 | function tryRecoverBrokenEmoji(tokens: Token[]): string | null { 804 | if (!tokens.length || !looksLikeEscapedUtf8(tokens.at(-1)!.text, false)) { 805 | return null; 806 | } 807 | 808 | const mask = tokens.map((t) => looksLikeEscapedUtf8(t.text, false)); 809 | // token position where every token after looks like broken utf-8 810 | // [false, true, false, true, true] 811 | // ^ start 812 | const start = mask.findIndex((_, idx) => mask.slice(idx, mask.length).every((v) => v)); 813 | 814 | for (let i = start; i < tokens.length; i++) { 815 | const joined = tokens 816 | .slice(start, tokens.length) 817 | .map((t) => t.text) 818 | .join(""); 819 | if (looksLikeEscapedUtf8(joined, true)) { 820 | const decoded = decodeEscapedUtf8(joined); 821 | if (decoded != null) { 822 | return decoded; 823 | } 824 | } 825 | } 826 | return null; 827 | } 828 | 829 | function looksLikeEscapedUtf8(s: string, strict: boolean): boolean { 830 | const wholeEscape = s.match(/^(\\x[0-9a-fA-F]{2})+$/g) !== null; 831 | if (strict) { 832 | return wholeEscape; 833 | } 834 | // also allow \\x -> aa split escapes 835 | return wholeEscape || s.match(/^((\\x)?[0-9a-fA-F]{1,2}|\\x)$/g) !== null; 836 | } 837 | 838 | function decodeEscapedUtf8(s: string): string | null { 839 | let rawBytes = ""; 840 | s.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => (rawBytes += String.fromCharCode(parseInt(hex, 16)))); 841 | 842 | const bytes = Uint8Array.from(rawBytes, (c) => c.charCodeAt(0)); 843 | try { 844 | return new TextDecoder("utf-8", { fatal: true }).decode(bytes); 845 | } catch (_) { 846 | return null; 847 | } 848 | } 849 | 850 | // Mount app 851 | 852 | document.addEventListener("DOMContentLoaded", () => { 853 | const appDiv = document.querySelector("#app"); 854 | if (!appDiv) { 855 | throw new Error("Missing #app div"); 856 | } 857 | 858 | ReactDOM.createRoot(appDiv).render(); 859 | }); 860 | -------------------------------------------------------------------------------- /logit-loom.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChatCompletion, 3 | ChatCompletionCreateParamsNonStreaming, 4 | ChatCompletionMessageParam, 5 | Completion, 6 | CompletionChoice, 7 | CompletionCreateParamsNonStreaming, 8 | } from "openai/resources/index.mjs"; 9 | import OpenAI from "./openai"; 10 | import * as uuid from "uuid"; 11 | 12 | import { type ApiInfo } from "./api-sniffer"; 13 | 14 | export interface Token { 15 | id: string; 16 | text: string; 17 | logprob: number; 18 | prob: number; 19 | /** If non-null, all tokens below this token will also be non-null, and this branch shouldn't be expanded. */ 20 | branchFinished: BranchFinishReason | null; 21 | children: Token[]; 22 | } 23 | 24 | export interface TreeOptions { 25 | client: InstanceType; 26 | baseUrl: string; 27 | apiInfo: ApiInfo; 28 | model: string; 29 | modelType: "chat" | "base"; 30 | systemPrompt?: string; 31 | prompt?: string; 32 | prefill?: string; 33 | depth: number; 34 | maxWidth: number; 35 | coverProb: number; 36 | progress: (tokens: Token[]) => boolean; 37 | } 38 | 39 | /** Build a fresh tree from the prompt / prefill */ 40 | export async function buildTree(opts: TreeOptions): Promise { 41 | const roots: Token[] = []; 42 | appendTokens(roots, await query([], opts)); 43 | console.log("roots", roots); 44 | if (opts.progress(structuredClone(roots))) { 45 | return roots; // interrupt 46 | } 47 | 48 | // repeatedly walk the tree until there are no nodes left to expand 49 | while (true) { 50 | const prefix = getContinuablePrefix(roots, opts.depth); 51 | if (prefix === null) { 52 | break; 53 | } 54 | appendTokens(prefix.at(-1)!, await query(prefix, opts)); 55 | if (opts.progress(structuredClone(roots))) { 56 | return roots; // interrupt 57 | } 58 | } 59 | return roots; 60 | } 61 | 62 | /** Expand an existing tree from the given id */ 63 | export async function expandTree(opts: TreeOptions, roots: Token[], id: string): Promise { 64 | roots = structuredClone(roots); 65 | opts = { ...opts, depth: opts.depth + 1 }; // to include the node itself 66 | const nodePath = pathToNodeWithId(id, roots); 67 | if (nodePath == null) { 68 | throw new Error(`node with id ${id} doesn't exist!`); 69 | } 70 | const node = nodePath.at(-1)!; 71 | node.children = []; 72 | const extraPrefill = nodePath 73 | .slice(0, -1) 74 | .map((t) => t.text) 75 | .join(""); 76 | console.log(node, extraPrefill, node.text); 77 | 78 | while (true) { 79 | const prefix = getContinuablePrefix([node], opts.depth); 80 | console.log(prefix); 81 | if (prefix === null) { 82 | break; 83 | } 84 | appendTokens(prefix.at(-1)!, await query(prefix, { ...opts, prefill: opts.prefill + extraPrefill })); 85 | if (opts.progress(structuredClone(roots))) { 86 | return roots; // interrupt 87 | } 88 | } 89 | return roots; 90 | } 91 | 92 | export function pathToNodeWithId(id: string, roots: Token[]): Token[] | null { 93 | for (let root of roots) { 94 | for (let traversal of _treeTraversals(root)) { 95 | const idx = traversal.findIndex((t) => t.id === id); 96 | if (idx === -1) { 97 | continue; 98 | } 99 | return traversal.slice(0, idx + 1); 100 | } 101 | } 102 | return null; 103 | } 104 | 105 | type BranchFinishReason = "stop" | "content_filter" | "tool_calls" | "function_call"; 106 | type QueriedLogprobs = 107 | | { 108 | kind: "logprobs"; 109 | logprobs: Array< 110 | TokenLogprobs & { 111 | /** If non-null, the **chosen** token branch is finished. (But other branches discovered here might be alive.) */ 112 | finishReason: BranchFinishReason | null; 113 | } 114 | >; 115 | } 116 | | { 117 | /** The entire branch you tried to query is finished. */ 118 | kind: "finish"; 119 | finishReason: BranchFinishReason; 120 | }; 121 | async function query(tokens: Token[], opts: TreeOptions): Promise { 122 | // TODO prompt concat is giga-broken for byte tokens with invalid unicode 123 | // 124 | // chat completions handles this with the Very Elegant solution of escaping them to \xaa\xbb and then sticking the bytes in a 125 | // `bytes` field on the logprob (as a list of integers) 126 | // we just naively take the text and then pass escapes into the model, which causes it to generate more escapes and 127 | // quickly enter byte-escapes-fucksville 128 | // 129 | // (regular completions doesn't handle this at all because fuck you) 130 | // 131 | // the problem is we can't work with the tokens one-by-one to turn the bytes into text because they are invalid unicode 132 | // on their own (e.g. \x20\xf0\x9f\x91 , missing the \x8b). we would instead need to concat their bytes and then try 133 | // to decode... but what if the trailing token is a partial? we'd need to insert a dummy token to make the sequence valid? 134 | // and how do we handle regular completions that don't have the bytes field? 135 | // 136 | // i hate BPE so much 137 | const prefill = (opts.prefill ?? "") + tokens.map((t) => t.text).join(""); 138 | const prefillStyle = opts.apiInfo.prefillStyle; 139 | 140 | let response: Completion | ChatCompletion; 141 | if (opts.modelType === "chat") { 142 | const messages: ChatCompletionMessageParam[] = []; 143 | if (opts.systemPrompt) { 144 | messages.push({ role: "system", content: opts.systemPrompt }); 145 | } 146 | messages.push({ role: "user", content: opts.prompt ?? "" }); 147 | if (prefill) { 148 | messages.push({ 149 | role: "assistant", 150 | content: (opts.prefill ?? "") + tokens.map((t) => t.text).join(""), 151 | ...(prefillStyle?.kind === "flags" && prefillStyle.target === "message" ? prefillStyle.flags : {}), 152 | }); 153 | } 154 | const request: ChatCompletionCreateParamsNonStreaming = { 155 | model: opts.model, 156 | messages, 157 | logprobs: true, 158 | top_logprobs: opts.maxWidth, 159 | max_tokens: opts.depth - tokens.length, 160 | temperature: opts.apiInfo.needsTemperature ?? 0.0, 161 | ...(prefillStyle?.kind === "flags" && prefillStyle?.target === "body" && messages.at(-1)?.role === "assistant" 162 | ? prefillStyle.flags 163 | : {}), 164 | }; 165 | console.log("chat request:", request); 166 | response = await opts.client.chat.completions.create(request); 167 | } else { 168 | const request: CompletionCreateParamsNonStreaming = { 169 | model: opts.model, 170 | prompt: opts.prompt + prefill, 171 | logprobs: opts.maxWidth, // TODO api claims the max for this is 5? probably only for openai? 172 | max_tokens: opts.depth - tokens.length, 173 | temperature: opts.apiInfo.needsTemperature ?? 0.0, 174 | }; 175 | console.log("completion request:", request); 176 | response = await opts.client.completions.create(request); 177 | } 178 | console.log("response:", response); 179 | 180 | const choice = response.choices[0]; 181 | if (choice == null) { 182 | throw new Error("response missing choices!"); 183 | } 184 | const logprobs = choice.logprobs != null ? extractLogprobs(choice.logprobs) : null; 185 | if (logprobs == null) { 186 | if (choice.finish_reason != null && choice.finish_reason !== "length") { 187 | // stopped because this branch is over 188 | return { kind: "finish", finishReason: choice.finish_reason }; 189 | } else if (choice.finish_reason === "length") { 190 | // TODO: sometimes can happen even though we count tokens, not sure why 191 | // seems to happen at natural endpoints, so count it as a stop for now 192 | console.warn("unexpected finish_reason=length!"); 193 | return { kind: "finish", finishReason: "stop" }; 194 | } else { 195 | throw new Error("response missing logprobs!"); 196 | } 197 | } 198 | return { 199 | kind: "logprobs", 200 | logprobs: logprobs.map(({ chosenToken, topLogprobs }) => { 201 | return { 202 | chosenToken, 203 | finishReason: choice.finish_reason == null || choice.finish_reason === "length" ? null : choice.finish_reason, 204 | // sometimes the API returns more logprobs than requested, so slice to maxWidth to avoid going too wide 205 | topLogprobs: sliceToProb(topLogprobs, opts.coverProb).slice(0, opts.maxWidth), 206 | }; 207 | }), 208 | }; 209 | } 210 | 211 | interface TokenLogprobs { 212 | chosenToken: string; 213 | topLogprobs: Array<{ token: string; logprob: number }>; 214 | } 215 | 216 | /** 217 | * Extract the logprobs from a response choice. This auto-detects the format instead of using `modelType`, because 218 | * some open-source APIs (not naming names...) return completion-style logprobs even for chat completions :-) 219 | */ 220 | function extractLogprobs( 221 | apiLogprobs: CompletionChoice.Logprobs | ChatCompletion.Choice.Logprobs 222 | ): TokenLogprobs[] | null { 223 | if (apiLogprobs.hasOwnProperty("content")) { 224 | // chat-style 225 | const content = (apiLogprobs as ChatCompletion.Choice.Logprobs).content; 226 | if (content == null) { 227 | return null; 228 | } 229 | return content.map((lp) => ({ 230 | chosenToken: lp.token, 231 | topLogprobs: lp.top_logprobs.toSorted((a, b) => -(a.logprob - b.logprob)), 232 | })); 233 | } else { 234 | const { tokens, top_logprobs } = apiLogprobs as CompletionChoice.Logprobs; 235 | if (tokens == null || top_logprobs == null) { 236 | return null; 237 | } 238 | return tokens.map((t, idx) => ({ 239 | chosenToken: t, 240 | topLogprobs: Object.entries(top_logprobs[idx]!) 241 | .map(([token, logprob]) => ({ token, logprob })) 242 | .toSorted((a, b) => -(a.logprob - b.logprob)), 243 | })); 244 | } 245 | } 246 | 247 | function appendTokens(parent: Token | Token[], queried: QueriedLogprobs) { 248 | if (queried.kind === "finish") { 249 | if (!Array.isArray(parent)) { 250 | parent.branchFinished = queried.finishReason; 251 | } else { 252 | parent.push({ 253 | id: uuid.v4(), 254 | text: `<|${queried.finishReason}|>`, 255 | logprob: 0, 256 | prob: 1, 257 | branchFinished: queried.finishReason, 258 | children: [], 259 | }); 260 | } 261 | return; 262 | } 263 | 264 | let to = Array.isArray(parent) ? parent : parent.children; 265 | for (let { chosenToken, finishReason, topLogprobs } of queried.logprobs) { 266 | for (let { token, logprob } of topLogprobs) { 267 | to.push({ 268 | id: uuid.v4(), 269 | text: token, 270 | logprob: logprob, 271 | prob: Math.exp(logprob), 272 | branchFinished: token === chosenToken ? finishReason : null, 273 | children: [], 274 | }); 275 | } 276 | const next = to.find((t) => t.text === chosenToken); 277 | if (next == null) { 278 | return; // chosen token was outside the top logprobs, no joy 279 | } 280 | to = next.children; 281 | } 282 | } 283 | 284 | /** Take a (sorted) list of top logprobs, and slice it to the shortest length that has a total probability > `prob` */ 285 | function sliceToProb(tokens: TokenLogprobs["topLogprobs"], prob: number): TokenLogprobs["topLogprobs"] { 286 | let cumprob = 0; 287 | let i = 0; 288 | while (cumprob < prob && i < tokens.length) { 289 | cumprob += Math.exp(tokens[i]!.logprob); 290 | i++; 291 | } 292 | return tokens.slice(0, i); 293 | } 294 | 295 | function getContinuablePrefix(roots: Token[], maxDepth: number): Token[] | null { 296 | for (let root of roots) { 297 | for (let traversal of _treeTraversals(root)) { 298 | const last = traversal.at(-1); 299 | if (last == null || traversal.length >= maxDepth) { 300 | continue; 301 | } 302 | if (last.children.length === 0 && !last.branchFinished) { 303 | return traversal; 304 | } 305 | } 306 | } 307 | return null; 308 | } 309 | 310 | function* _treeTraversals(token: Token): Generator { 311 | if (token.children.length === 0) { 312 | yield [token]; 313 | } else { 314 | for (let child of token.children) { 315 | for (let subtraversal of _treeTraversals(child)) { 316 | yield [token, ...subtraversal]; 317 | } 318 | } 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /media/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgel/logitloom/3a6be56fbf53e28b43ab0fbd14b357d5d444bfa3/media/hero.png -------------------------------------------------------------------------------- /media/node-chips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgel/logitloom/3a6be56fbf53e28b43ab0fbd14b357d5d444bfa3/media/node-chips.png -------------------------------------------------------------------------------- /media/utf8-repair.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgel/logitloom/3a6be56fbf53e28b43ab0fbd14b357d5d444bfa3/media/utf8-repair.png -------------------------------------------------------------------------------- /openai.ts: -------------------------------------------------------------------------------- 1 | // workaround for a bun bundler bug -- see ./vendor-openai.sh for more info 2 | 3 | import VendoredOpenAI from "./vendored/openai.js"; 4 | import type OpenAI from "openai"; 5 | 6 | const _OpenAI: typeof OpenAI = VendoredOpenAI; 7 | export default _OpenAI; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logittree", 3 | "module": "index.ts", 4 | "type": "module", 5 | "private": true, 6 | "devDependencies": { 7 | "@types/bun": "latest", 8 | "@types/d3": "^7.4.3", 9 | "@types/react": "^19.1.2", 10 | "@types/react-dom": "^19.1.3", 11 | "openai": "^4.97.0" 12 | }, 13 | "peerDependencies": { 14 | "typescript": "^5" 15 | }, 16 | "dependencies": { 17 | "d3": "^7.9.0", 18 | "react": "^19.1.0", 19 | "react-dom": "^19.1.0", 20 | "use-local-storage-state": "^19.5.0", 21 | "uuid": "^11.1.0" 22 | }, 23 | "scripts": { 24 | "postinstall": "./vendor-openai.sh" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /save-load.ts: -------------------------------------------------------------------------------- 1 | import { type Token } from "./logit-loom"; 2 | 3 | export interface SerializedTree { 4 | isLogitLoomTreeVersion: "logit-loom-tree-v1"; 5 | modelName: string; 6 | modelSettings: 7 | | { 8 | kind: "chat"; 9 | systemPrompt?: string; 10 | prompt?: string; 11 | prefill?: string; 12 | } 13 | | { 14 | kind: "base"; 15 | prompt?: string; 16 | prefill?: string; 17 | }; 18 | roots: Token[]; 19 | } 20 | 21 | export function saveTree(serialized: SerializedTree) { 22 | const jsonData = JSON.stringify(serialized, null, 2); 23 | 24 | const blob = new Blob([jsonData], { type: "application/json" }); 25 | const url = URL.createObjectURL(blob); 26 | 27 | const link = document.createElement("a"); 28 | link.style.display = "none"; 29 | link.href = url; 30 | const date = new Date().toLocaleDateString("sv"); // iso format date, thank you sweden 31 | link.download = `logitloom-${serialized.modelName}-${date}.ll.json`; 32 | document.body.appendChild(link); 33 | link.click(); 34 | 35 | document.body.removeChild(link); 36 | URL.revokeObjectURL(url); 37 | } 38 | 39 | export function loadTree(opts: { 40 | onDone: (tree: SerializedTree) => void; 41 | onError: (err: unknown) => void; 42 | onCancel: () => void; 43 | }): void { 44 | const input = document.createElement("input"); 45 | input.type = "file"; 46 | input.accept = "application/json"; 47 | input.style.display = "none"; 48 | 49 | const cleanup = () => { 50 | if (input.parentNode) document.body.removeChild(input); 51 | }; 52 | 53 | input.addEventListener("change", () => { 54 | const file = input.files?.[0]; 55 | if (file == null) { 56 | cleanup(); 57 | opts.onCancel(); 58 | return; 59 | } 60 | 61 | const reader = new FileReader(); 62 | 63 | reader.addEventListener("load", () => { 64 | try { 65 | const data = JSON.parse(reader.result as string); 66 | if ((data as SerializedTree).isLogitLoomTreeVersion === "logit-loom-tree-v1") { 67 | opts.onDone(data as SerializedTree); 68 | } else { 69 | console.error("loadTree: not a tree:", data); 70 | opts.onError(`File was not a logitloom tree.`); 71 | } 72 | } catch (err) { 73 | console.error("loadTree:", err); 74 | opts.onError(err); 75 | } finally { 76 | cleanup(); 77 | } 78 | }); 79 | 80 | reader.addEventListener("error", () => { 81 | console.error("loadTree:", reader.error); 82 | opts.onError(reader.error); 83 | cleanup(); 84 | }); 85 | 86 | reader.readAsText(file); 87 | }); 88 | 89 | document.body.appendChild(input); 90 | input.click(); 91 | } 92 | -------------------------------------------------------------------------------- /tree-store.ts: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from "react"; 2 | import OpenAI from "./openai"; 3 | 4 | import { buildTree, expandTree, pathToNodeWithId, type Token } from "./logit-loom"; 5 | import { type ApiInfo, sniffApi } from "./api-sniffer"; 6 | import * as SaveLoad from "./save-load"; 7 | 8 | export interface State { 9 | running: boolean; 10 | interrupting: boolean; 11 | value: 12 | | { kind: "tree"; roots: Token[] } 13 | | { 14 | kind: "error"; 15 | error: any; 16 | /** The previous roots before the error, if they existed. */ 17 | roots: Token[] | null; 18 | }; 19 | baseUrlApiInfoCache: Record; 20 | } 21 | 22 | const { useTreeStore: _useTreeStore, updateState } = (() => { 23 | let listeners: Array<() => void> = []; 24 | let _state: State = { 25 | running: false, 26 | interrupting: false, 27 | value: { kind: "tree", roots: [] }, 28 | baseUrlApiInfoCache: {}, 29 | }; 30 | 31 | function subscribe(listener: () => void): () => void { 32 | listeners = [...listeners, listener]; 33 | return () => { 34 | listeners = listeners.filter((l) => l !== listener); 35 | }; 36 | } 37 | 38 | function emitChange() { 39 | for (let listener of listeners) { 40 | listener(); 41 | } 42 | } 43 | 44 | function getSnapshot(): State { 45 | return _state; 46 | } 47 | 48 | return { 49 | useTreeStore: (): State => { 50 | return useSyncExternalStore(subscribe, getSnapshot); 51 | }, 52 | updateState: (update: (oldState: State) => State): void => { 53 | const newState = update(_state); 54 | if (newState !== _state) { 55 | _state = newState; 56 | emitChange(); 57 | } 58 | }, 59 | }; 60 | })(); 61 | 62 | export const useTreeStore = _useTreeStore; 63 | 64 | // Helper functions 65 | // These functions should always take a state, instead of fetching state from the module. (This is why the current state is private.) 66 | // The reason for this is if a component just uses one of these functions, it needs to have the state passed into it somehow (either via useTreeStore or as a prop) 67 | // so that react knows to rerender it when the state changes. If the helper function pulls `state` directly, then react doesn't know about the dependency on state. 68 | // Using `updateState` is fine, however. 69 | 70 | /** Return the token string for a given token -- all the tokens before it, and itself, joined together. */ 71 | export function getTokenAndPrefix(state: State, id: string): string | null { 72 | if (state.value.roots == null) { 73 | return null; 74 | } 75 | const path = pathToNodeWithId(id, state.value.roots); 76 | if (path === null) { 77 | return null; 78 | } 79 | return path.map((t) => t.text).join(""); 80 | } 81 | 82 | export function loadTreeFromLocalStorage() { 83 | updateState((state) => ({ ...state, value: { kind: "tree", roots: tryGetTreeFromLocalStorage() } })); 84 | } 85 | 86 | export function setTree(roots: Token[]) { 87 | updateState((state) => { 88 | if (state.running) { 89 | return state; 90 | } 91 | return { ...state, value: { kind: "tree", roots } }; 92 | }); 93 | } 94 | 95 | export type SerializedModelSettings = SaveLoad.SerializedTree["modelSettings"]; 96 | 97 | export function saveTree(state: State, modelName: string, modelSettings: SerializedModelSettings) { 98 | const roots = state.value.roots; 99 | if (state.running || roots == null) { 100 | return; 101 | } 102 | 103 | SaveLoad.saveTree({ 104 | isLogitLoomTreeVersion: "logit-loom-tree-v1", 105 | modelName, 106 | modelSettings, 107 | roots, 108 | }); 109 | } 110 | 111 | export function loadTree(importSettings: (modelName: string, modelSettings: SerializedModelSettings) => void) { 112 | SaveLoad.loadTree({ 113 | onDone: (tree) => { 114 | updateState((state) => { 115 | if (state.running) { 116 | return state; 117 | } 118 | importSettings(tree.modelName, tree.modelSettings); 119 | return { ...state, value: { kind: "tree", roots: tree.roots } }; 120 | }); 121 | }, 122 | onError: (error) => { 123 | updateState((state) => { 124 | if (state.running) { 125 | return state; 126 | } 127 | return { ...state, value: { kind: "error", error, roots: state.value.roots } }; 128 | }); 129 | }, 130 | onCancel: () => {}, 131 | }); 132 | } 133 | 134 | export function interruptRun() { 135 | updateState((state) => { 136 | if (!state.running || state.interrupting) { 137 | return state; 138 | } 139 | return { ...state, interrupting: true }; 140 | }); 141 | } 142 | 143 | export function run( 144 | prevState: State, 145 | opts: { 146 | baseUrl: string; 147 | apiKey: string; 148 | modelName: string; 149 | modelType: "chat" | "base"; 150 | systemPrompt: string | undefined; 151 | prompt: string | undefined; 152 | prefill: string | undefined; 153 | depth: number; 154 | maxWidth: number; 155 | coverProb: number; 156 | fromNodeId?: string; 157 | } 158 | ) { 159 | if (prevState.running) { 160 | return; 161 | } 162 | 163 | const client = new OpenAI({ 164 | baseURL: opts.baseUrl, 165 | apiKey: opts.apiKey, 166 | dangerouslyAllowBrowser: true, 167 | }); 168 | 169 | updateState((state) => ({ ...state, running: true })); 170 | 171 | async function getApiInfo(): Promise { 172 | if (!isProbablyLocalhost(opts.baseUrl)) { 173 | // don't *use* cache for localhost because it's liable to change if the user runs a new server 174 | // but we still store it for the UI to render warnings 175 | const cachedApiInfo = prevState.baseUrlApiInfoCache[opts.baseUrl]; 176 | if (cachedApiInfo != null) { 177 | return cachedApiInfo; 178 | } 179 | } 180 | const apiInfo = await sniffApi(opts.baseUrl, opts.apiKey); 181 | updateState((state) => ({ 182 | ...state, 183 | baseUrlApiInfoCache: { ...state.baseUrlApiInfoCache, [opts.baseUrl]: apiInfo }, 184 | })); 185 | return apiInfo; 186 | } 187 | 188 | function progress(roots: Token[]) { 189 | trySyncTreeToLocalStorage(roots); 190 | // TODO: this is kinda gross, not supposed to smuggle values out of state like this, but it's OK because this isn't visible to react 191 | let interrupting = false; 192 | updateState((state) => { 193 | interrupting = state.interrupting; 194 | return { ...state, value: { kind: "tree", roots } }; 195 | }); 196 | return interrupting; // interrupt if user requested it 197 | } 198 | 199 | let promise: Promise; 200 | const fromNodeId = opts.fromNodeId; 201 | if (fromNodeId == null) { 202 | promise = getApiInfo().then((apiInfo) => 203 | buildTree({ 204 | client, 205 | baseUrl: opts.baseUrl, 206 | apiInfo, 207 | model: opts.modelName, 208 | modelType: opts.modelType, 209 | systemPrompt: opts.systemPrompt, 210 | prompt: opts.prompt, 211 | prefill: opts.prefill, 212 | depth: opts.depth, 213 | maxWidth: opts.maxWidth, 214 | coverProb: opts.coverProb, 215 | progress, 216 | }) 217 | ); 218 | } else { 219 | const roots = prevState.value.roots; 220 | if (roots == null) { 221 | throw new Error(`ui bug: state missing tree, can't expand '${opts.fromNodeId}' (how did you get this id?)`); 222 | } 223 | promise = getApiInfo().then((apiInfo) => 224 | expandTree( 225 | { 226 | client, 227 | baseUrl: opts.baseUrl, 228 | apiInfo, 229 | model: opts.modelName, 230 | modelType: opts.modelType, 231 | systemPrompt: opts.systemPrompt, 232 | prompt: opts.prompt, 233 | prefill: opts.prefill, 234 | depth: opts.depth, 235 | maxWidth: opts.maxWidth, 236 | coverProb: opts.coverProb, 237 | progress, 238 | }, 239 | roots, 240 | fromNodeId 241 | ) 242 | ); 243 | } 244 | 245 | promise 246 | .then((roots) => { 247 | trySyncTreeToLocalStorage(roots); 248 | updateState((state) => ({ ...state, value: { kind: "tree", roots }, running: false, interrupting: false })); 249 | }) 250 | .catch((error) => { 251 | console.error(error); 252 | updateState((state) => ({ 253 | ...state, 254 | running: false, 255 | interrupting: false, 256 | value: { kind: "error", error, roots: state.value.roots }, 257 | })); 258 | }); 259 | } 260 | 261 | // TODO: this was added before save-load.ts, but it would be nice to unify the mechanisms 262 | const treeLocalStorageKey = "prevTree"; 263 | 264 | /** Get the previous tree from localStorage, or an empty tree on error / missing tree. */ 265 | function tryGetTreeFromLocalStorage(): Token[] { 266 | try { 267 | const value = localStorage.getItem(treeLocalStorageKey); 268 | if (value == null) { 269 | return []; 270 | } 271 | const tree = JSON.parse(value); 272 | if (!Array.isArray(tree)) { 273 | // TODO: basic validation, should really parse this with zod or something 274 | console.warn(`item in prevTree doesn't seem to be a tree?`, tree); 275 | return []; 276 | } 277 | console.log("loaded tree from localStorage:", tree); 278 | return tree as Token[]; 279 | } catch (e) { 280 | console.error("getting tree from localStorage:", e); 281 | return []; 282 | } 283 | } 284 | 285 | /** Attempt to sync the tree to localStorage. */ 286 | function trySyncTreeToLocalStorage(roots: Token[]) { 287 | try { 288 | localStorage.setItem(treeLocalStorageKey, JSON.stringify(roots)); 289 | } catch (e) { 290 | console.error("persisting tree to localStorage:", e); 291 | } 292 | } 293 | 294 | function isProbablyLocalhost(url: string): boolean { 295 | return ( 296 | url.includes("//localhost") || 297 | url.includes("//127.0.0") || 298 | url.includes("//[::1]") || 299 | url.includes("//[0:0:0:0:0:0:0:1]") 300 | ); 301 | } 302 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /vendor-openai.sh: -------------------------------------------------------------------------------- 1 | # bun (a/o 1.2.13 canary) has issues importing openai in the browser due to 2 | # circular imports in the package. until that's fixed, we vendor it as a .js 3 | # file in the repo. 4 | # this script takes the currently installed openai package, and vendors it 5 | 6 | echo "(vendor-openai.sh) vendoring openai to vendored/openai.js..." 7 | bunx esbuild node_modules/openai/index.js \ 8 | --log-level=warning \ 9 | --bundle \ 10 | --format=esm \ 11 | --platform=browser \ 12 | --target=es2020 \ 13 | --define:process.env.NODE_ENV='"production"' \ 14 | --outfile=vendored/openai.js 15 | --------------------------------------------------------------------------------