├── .env.example ├── .github └── workflows │ ├── deploy-to-spaces.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.mjs ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── e2e └── home.test.ts ├── eslint-rules └── enforce-extensions.js ├── eslint.config.mts ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── scripts ├── setup-playwright-arch.sh └── update-ctx-length.ts ├── src ├── app.css ├── app.d.ts ├── app.html ├── lib │ ├── attachments │ │ ├── autofocus.ts │ │ ├── click-outside.ts │ │ └── observe.svelte.ts │ ├── components │ │ ├── avatar.svelte │ │ ├── debug-menu.svelte │ │ ├── dialog.svelte │ │ ├── icon-custom.svelte │ │ ├── icon-provider.svelte │ │ ├── inference-playground │ │ │ ├── checkpoints-menu.svelte │ │ │ ├── code-snippets.svelte │ │ │ ├── conversation-header.svelte │ │ │ ├── conversation.svelte │ │ │ ├── custom-model-config.svelte │ │ │ ├── custom-provider-select.svelte │ │ │ ├── generation-config-settings.ts │ │ │ ├── generation-config.svelte │ │ │ ├── hf-token-modal.svelte │ │ │ ├── img-preview.svelte │ │ │ ├── message.svelte │ │ │ ├── model-selector-modal.svelte │ │ │ ├── model-selector.svelte │ │ │ ├── playground.svelte │ │ │ ├── project-select.svelte │ │ │ ├── provider-select.svelte │ │ │ ├── schema-property.svelte │ │ │ └── structured-output-modal.svelte │ │ ├── label-pro.svelte │ │ ├── local-toasts.svelte │ │ ├── prompts.svelte │ │ ├── quota-modal.svelte │ │ ├── share-modal.svelte │ │ ├── toaster.svelte │ │ ├── toaster.svelte.ts │ │ └── tooltip.svelte │ ├── constants.ts │ ├── data │ │ └── context_length.json │ ├── remult.ts │ ├── server │ │ ├── api.ts │ │ └── providers │ │ │ ├── cohere.ts │ │ │ ├── fireworks.ts │ │ │ ├── hyperbolic.ts │ │ │ ├── index.ts │ │ │ ├── nebius.ts │ │ │ ├── novita.ts │ │ │ ├── replicate.ts │ │ │ ├── sambanova.ts │ │ │ └── together.ts │ ├── spells │ │ ├── README.md │ │ ├── abort-manager.svelte.ts │ │ ├── create-init.svelte.ts │ │ ├── extract.svelte.ts │ │ ├── is-dark.svelte.ts │ │ ├── scroll-state.svelte.ts │ │ ├── synced.svelte.ts │ │ └── textarea-autosize.svelte.ts │ ├── state │ │ ├── checkpoints.svelte.ts │ │ ├── conversations.svelte.ts │ │ ├── images.svelte.test.ts │ │ ├── images.svelte.ts │ │ ├── models.svelte.ts │ │ ├── projects.svelte.ts │ │ └── token.svelte.ts │ ├── types.ts │ └── utils │ │ ├── array.ts │ │ ├── business.svelte.ts │ │ ├── cn.ts │ │ ├── compare.ts │ │ ├── copy.ts │ │ ├── date.ts │ │ ├── encode.ts │ │ ├── file.ts │ │ ├── form.svelte.ts │ │ ├── is.ts │ │ ├── json.ts │ │ ├── lifecycle.ts │ │ ├── noop.ts │ │ ├── object.svelte.ts │ │ ├── platform.ts │ │ ├── poll.ts │ │ ├── queue.ts │ │ ├── search.ts │ │ ├── sleep.ts │ │ ├── snippets.spec.ts │ │ ├── snippets.ts │ │ ├── store.ts │ │ ├── string.ts │ │ ├── template.ts │ │ └── url.ts └── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ ├── +page.ts │ ├── api │ ├── [...remult] │ │ └── +server.ts │ └── models │ │ └── +server.ts │ └── usage │ └── +page.svelte ├── static ├── banner-dark.svg ├── banner-light.svg └── favicon.png ├── svelte.config.js ├── tsconfig.json ├── vite.config.ts └── vitest-setup-client.ts /.env.example: -------------------------------------------------------------------------------- 1 | HYPERBOLIC_API_KEY= 2 | COHERE_API_KEY= 3 | TOGETHER_API_KEY= 4 | FIREWORKS_API_KEY= 5 | REPLICATE_API_KEY= 6 | NEBIUS_API_KEY= 7 | NOVITA_API_KEY= 8 | FAL_API_KEY= 9 | HF_TOKEN= 10 | 11 | MODELS_FILE= 12 | -------------------------------------------------------------------------------- /.github/workflows/deploy-to-spaces.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Spaces 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | # to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test-build: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "20" 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v2 20 | with: 21 | version: latest 22 | run_install: false 23 | - name: Get pnpm store directory 24 | id: pnpm-cache 25 | shell: bash 26 | run: | 27 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 28 | - uses: actions/cache@v3 29 | with: 30 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 31 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pnpm-store- 34 | - name: Install dependencies 35 | run: pnpm install --frozen-lockfile 36 | - name: "Build" 37 | run: | 38 | pnpm run build 39 | sync-to-hub: 40 | needs: test-build 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v3 44 | with: 45 | fetch-depth: 0 46 | lfs: true 47 | - name: Push to hub 48 | env: 49 | HF_DEPLOYMENT_TOKEN: ${{ secrets.HF_DEPLOYMENT_TOKEN }} 50 | run: git push -f https://mishig:$HF_DEPLOYMENT_TOKEN@huggingface.co/spaces/huggingface/inference-playground main 51 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: "20" 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: latest 20 | run_install: false 21 | - name: Get pnpm store directory 22 | id: pnpm-cache 23 | shell: bash 24 | run: | 25 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 26 | - uses: actions/cache@v3 27 | with: 28 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 29 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pnpm-store- 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | - name: "Checking lint/format errors" 35 | run: | 36 | pnpm run lint 37 | - name: "Checking type errors" 38 | run: | 39 | pnpm run check 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: "20" 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: latest 20 | run_install: false 21 | - name: Get pnpm store directory 22 | id: pnpm-cache 23 | shell: bash 24 | run: | 25 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 26 | - uses: actions/cache@v3 27 | with: 28 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 29 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pnpm-store- 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | - name: Install Playwright Browsers 35 | run: npx playwright install --with-deps 36 | - name: "Running tests" 37 | run: | 38 | pnpm run test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test-results 2 | node_modules 3 | 4 | # Output 5 | .output 6 | .vercel 7 | /.svelte-kit 8 | /build 9 | 10 | # OS 11 | .DS_Store 12 | Thumbs.db 13 | 14 | # Env 15 | .env 16 | .env.* 17 | !.env.example 18 | !.env.test 19 | 20 | # Vite 21 | vite.config.js.timestamp-* 22 | vite.config.ts.timestamp-* 23 | .aider* 24 | 25 | # Model JSON file 26 | models.json 27 | 28 | e2e/*.json 29 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | .pnpm-store 6 | 7 | .DS_Store 8 | node_modules 9 | /build 10 | /.svelte-kit 11 | /package 12 | .env 13 | .env.* 14 | !.env.example 15 | 16 | # Ignore files for PNPM, NPM and YARN 17 | pnpm-lock.yaml 18 | yarn.lock 19 | 20 | context_length.json 21 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | arrowParens: "avoid", 3 | quoteProps: "consistent", 4 | trailingComma: "es5", 5 | useTabs: true, 6 | tabWidth: 2, 7 | printWidth: 120, 8 | overrides: [{ files: "*.svelte", options: { parser: "svelte" } }], 9 | tailwindConfig: "./tailwind.config.ts", 10 | plugins: [import("prettier-plugin-svelte"), import("prettier-plugin-tailwindcss")], 11 | }; 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a 6 | harassment-free experience for everyone, regardless of age, body size, visible or invisible 7 | disability, ethnicity, sex characteristics, gender identity and expression, level of experience, 8 | education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or 9 | sexual identity and orientation. 10 | 11 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and 12 | healthy community. 13 | 14 | ## Our Standards 15 | 16 | Examples of behavior that contributes to a positive environment for our community include: 17 | 18 | - Demonstrating empathy and kindness toward other people 19 | - Being respectful of differing opinions, viewpoints, and experiences 20 | - Giving and gracefully accepting constructive feedback 21 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the 22 | experience 23 | - Focusing on what is best not just for us as individuals, but for the overall community 24 | 25 | Examples of unacceptable behavior include: 26 | 27 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 28 | - Trolling, insulting or derogatory comments, and personal or political attacks 29 | - Public or private harassment 30 | - Publishing others' private information, such as a physical or email address, without their 31 | explicit permission 32 | - Other conduct which could reasonably be considered inappropriate in a professional setting 33 | 34 | ## Enforcement Responsibilities 35 | 36 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior 37 | and will take appropriate and fair corrective action in response to any behavior that they deem 38 | inappropriate, threatening, offensive, or harmful. 39 | 40 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, 41 | code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and 42 | will communicate reasons for moderation decisions when appropriate. 43 | 44 | ## Scope 45 | 46 | This Code of Conduct applies within all community spaces, and also applies when an individual is 47 | officially representing the community in public spaces. Examples of representing our community 48 | include using an official e-mail address, posting via an official social media account, or acting as 49 | an appointed representative at an online or offline event. 50 | 51 | ## Enforcement 52 | 53 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community 54 | leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and 55 | investigated promptly and fairly. 56 | 57 | All community leaders are obligated to respect the privacy and security of the reporter of any 58 | incident. 59 | 60 | ## Enforcement Guidelines 61 | 62 | Community leaders will follow these Community Impact Guidelines in determining the consequences for 63 | any action they deem in violation of this Code of Conduct: 64 | 65 | ### 1. Correction 66 | 67 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or 68 | unwelcome in the community. 69 | 70 | **Consequence**: A private, written warning from community leaders, providing clarity around the 71 | nature of the violation and an explanation of why the behavior was inappropriate. A public apology 72 | may be requested. 73 | 74 | ### 2. Warning 75 | 76 | **Community Impact**: A violation through a single incident or series of actions. 77 | 78 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people 79 | involved, including unsolicited interaction with those enforcing the Code of Conduct, for a 80 | specified period of time. This includes avoiding interactions in community spaces as well as 81 | external channels like social media. Violating these terms may lead to a temporary or permanent ban. 82 | 83 | ### 3. Temporary Ban 84 | 85 | **Community Impact**: A serious violation of community standards, including sustained inappropriate 86 | behavior. 87 | 88 | **Consequence**: A temporary ban from any sort of interaction or public communication with the 89 | community for a specified period of time. No public or private interaction with the people involved, 90 | including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this 91 | period. Violating these terms may lead to a permanent ban. 92 | 93 | ### 4. Permanent Ban 94 | 95 | **Community Impact**: Demonstrating a pattern of violation of community standards, including 96 | sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement 97 | of classes of individuals. 98 | 99 | **Consequence**: A permanent ban from any sort of public interaction within the community. 100 | 101 | ## Attribution 102 | 103 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at 104 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 105 | 106 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement 107 | ladder][Mozilla CoC]. 108 | 109 | For answers to common questions about this code of conduct, see the FAQ at 110 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 111 | [https://www.contributor-covenant.org/translations][translations]. 112 | 113 | [homepage]: https://www.contributor-covenant.org 114 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 115 | [Mozilla CoC]: https://github.com/mozilla/diversity 116 | [FAQ]: https://www.contributor-covenant.org/faq 117 | [translations]: https://www.contributor-covenant.org/translations 118 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | # Install pnpm 4 | RUN npm install -g pnpm 5 | 6 | # Set the working directory 7 | WORKDIR /app 8 | 9 | # Copy package.json and pnpm-lock.yaml 10 | COPY package.json pnpm-lock.yaml* ./ 11 | 12 | # Install all dependencies, including dev dependencies 13 | RUN pnpm install --frozen-lockfile 14 | 15 | # Copy the rest of the application code 16 | COPY . . 17 | 18 | # Build the application 19 | RUN pnpm run build 20 | 21 | # Prune dev dependencies 22 | RUN pnpm prune --prod 23 | 24 | # Set correct permissions 25 | RUN chown -R node:node /app 26 | 27 | # Switch to non-root user 28 | USER node 29 | 30 | # Expose the port the app runs on 31 | EXPOSE 3000 32 | 33 | # Start the application 34 | CMD ["node", "build"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hugging Face 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Inference Playground 3 | emoji: 🔋 4 | colorFrom: blue 5 | colorTo: pink 6 | sdk: docker 7 | pinned: false 8 | app_port: 3000 9 | --- 10 | 11 |

12 | 13 | 14 | 15 | Hugging Face Inference Playground 16 | 17 |

18 | 19 |

20 | Build 21 | GitHub 22 | Contributor Covenant 23 |

24 | 25 | This application provides a user interface to interact with various large language models, leveraging the `@huggingface/inference` library. It allows you to easily test and compare models hosted on Hugging Face, connect to different third-party Inference Providers, and even configure your own custom OpenAI-compatible endpoints. 26 | 27 | ## Local Setup 28 | 29 | TL;DR: After cloning, run `pnpm i && pnpm run dev --open` 30 | 31 | ### Prerequisites 32 | 33 | Before you begin, ensure you have the following installed: 34 | 35 | - **Node.js:** Version 20 or later is recommended. 36 | - **pnpm:** Install it globally via `npm install -g pnpm`. 37 | - **Hugging Face Account & Token:** You'll need a free Hugging Face account and an access token to interact with models. Generate a token with at least `read` permissions from [hf.co/settings/tokens](https://huggingface.co/settings/tokens). 38 | 39 | Follow these steps to get the Inference Playground running on your local machine: 40 | 41 | 1. **Clone the Repository:** 42 | 43 | ```bash 44 | git clone https://github.com/huggingface/inference-playground.git 45 | cd inference-playground 46 | ``` 47 | 48 | 2. **Install Dependencies:** 49 | 50 | ```bash 51 | pnpm install 52 | ``` 53 | 54 | 3. **Start the Development Server:** 55 | 56 | ```bash 57 | pnpm run dev 58 | ``` 59 | 60 | 4. **Access the Playground:** 61 | - Open your web browser and navigate to `http://localhost:5173` (or the port indicated in your terminal). 62 | 63 | ## Features 64 | 65 | - **Model Interaction:** Chat with a wide range of models available through Hugging Face Inference. 66 | - **Provider Support:** Connect to various third-party inference providers (like Together, Fireworks, Replicate, etc.). 67 | - **Custom Endpoints:** Add and use your own OpenAI-compatible API endpoints. 68 | - **Comparison View:** Run prompts against two different models or configurations side-by-side. 69 | - **Configuration:** Adjust generation parameters like temperature, max tokens, and top-p. 70 | - **Session Management:** Save and load your conversation setups using Projects and Checkpoints. 71 | - **Code Snippets:** Generate code snippets for various languages to replicate your inference calls. 72 | 73 | We hope you find the Inference Playground useful for exploring and experimenting with language models! 74 | -------------------------------------------------------------------------------- /e2e/home.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { TEST_IDS } from "../src/lib/constants.js"; 3 | 4 | const HF_TOKEN = "hf_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 5 | const HF_TOKEN_STORAGE_KEY = "hf_token"; 6 | const STORAGE_STATE_FILE = "e2e/home_test_storage_state.json"; 7 | 8 | test("home page has expected token model", async ({ page }) => { 9 | await page.goto("/"); 10 | await expect(page.getByText("Add a Hugging Face Token")).toBeVisible(); 11 | }); 12 | 13 | // Group tests that depend on sequential execution and shared state 14 | test.describe.serial("Token Handling and Subsequent Tests", () => { 15 | // Test that sets the token and saves state 16 | test("filling up token input, closes modal, and saves state", async ({ page }) => { 17 | await page.goto("/"); 18 | await expect(page.getByText("Add a Hugging Face Token")).toBeVisible(); 19 | 20 | const input = page.getByPlaceholder("Enter HF Token"); 21 | await expect(input).toBeVisible(); 22 | await input.fill(HF_TOKEN); 23 | await input.blur(); 24 | 25 | await page.getByText("Submit").click(); 26 | await expect(page.getByText("Add a Hugging Face Token")).not.toBeVisible(); 27 | 28 | // Save storage state 29 | await page.context().storageState({ path: STORAGE_STATE_FILE }); 30 | }); 31 | 32 | // Nested describe for tests that use the saved state 33 | test.describe("Tests requiring persisted token", () => { 34 | test.use({ storageState: STORAGE_STATE_FILE }); 35 | 36 | test("can create a conversation with persisted token", async ({ page }) => { 37 | await page.goto("/"); 38 | 39 | // Expect modal NOT to be visible due to persisted token 40 | await expect(page.getByText("Add a Hugging Face Token")).not.toBeVisible(); 41 | 42 | // Verify token is in localStorage 43 | const storedToken = await page.evaluate( 44 | key => JSON.parse(window.localStorage.getItem(key) ?? ""), 45 | HF_TOKEN_STORAGE_KEY 46 | ); 47 | expect(storedToken).toBe(HF_TOKEN); 48 | 49 | const userInput = page.getByRole("textbox", { name: "Enter user message" }); 50 | await expect(userInput).toBeVisible(); 51 | await userInput.fill("Hello Hugging Face!"); 52 | await userInput.blur(); 53 | expect(await userInput.inputValue()).toBe("Hello Hugging Face!"); 54 | 55 | // Reload the page 56 | await page.reload(); 57 | 58 | // Re-select the input field and check its value 59 | const userInputAfterReload = page.getByRole("textbox", { name: "Enter user message" }); 60 | await expect(userInputAfterReload).toBeVisible(); 61 | expect(await userInputAfterReload.inputValue()).toBe("Hello Hugging Face!"); 62 | }); 63 | 64 | test("checkpoints, resetting, and restoring", async ({ page }) => { 65 | await page.goto("/"); 66 | const userMsg = "user message: hi"; 67 | const assistantMsg = "assistant message: hey"; 68 | 69 | // Fill user message 70 | await page.getByRole("textbox", { name: "Enter user message" }).click(); 71 | await page.getByRole("textbox", { name: "Enter user message" }).fill(userMsg); 72 | // Blur 73 | await page.locator(".relative > div:nth-child(2) > div").first().click(); 74 | 75 | // Fill assistant message 76 | await page.getByRole("button", { name: "Add message" }).click(); 77 | await page.getByRole("textbox", { name: "Enter assistant message" }).fill(assistantMsg); 78 | // Blur 79 | await page.locator(".relative > div:nth-child(2) > div").first().click(); 80 | 81 | // Create Checkpoint 82 | await page.locator(`[data-test-id="${TEST_IDS.checkpoints_trigger}"]`).click(); 83 | await page.getByRole("button", { name: "Create new" }).click(); 84 | 85 | // Check that there are checkpoints 86 | await expect(page.locator(`[data-test-id="${TEST_IDS.checkpoint}"] `)).toBeVisible(); 87 | 88 | // Get out of menu 89 | await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape"); 90 | await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape"); 91 | 92 | // Reset 93 | await page.locator(`[data-test-id="${TEST_IDS.reset}"]`).click(); 94 | 95 | // Check that messages are gone now 96 | await expect(page.getByRole("textbox", { name: "Enter user message" })).toHaveValue(""); 97 | 98 | // Call in a checkpoint 99 | await page.locator(`[data-test-id="${TEST_IDS.checkpoints_trigger}"]`).click(); 100 | await page.locator(`[data-test-id="${TEST_IDS.checkpoint}"] `).click(); 101 | 102 | // Get out of menu 103 | await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape"); 104 | await page.locator(`[data-test-id="${TEST_IDS.checkpoints_menu}"] `).press("Escape"); 105 | 106 | // Check that the messages are back 107 | await expect(page.getByRole("textbox", { name: "Enter user message" })).toHaveValue(userMsg); 108 | await expect(page.getByRole("textbox", { name: "Enter assistant message" })).toHaveValue(assistantMsg); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /eslint-rules/enforce-extensions.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export default { 5 | meta: { 6 | type: "suggestion", 7 | docs: { 8 | description: "Enforce file extensions in import statements", 9 | }, 10 | fixable: "code", 11 | schema: [ 12 | { 13 | type: "object", 14 | properties: { 15 | ignorePaths: { 16 | type: "array", 17 | items: { type: "string" }, 18 | }, 19 | includePaths: { 20 | type: "array", 21 | items: { type: "string" }, 22 | description: "Path patterns to include (e.g., '$lib/')", 23 | }, 24 | tsToJs: { 25 | type: "boolean", 26 | description: "Convert .ts files to .js when importing", 27 | }, 28 | aliases: { 29 | type: "object", 30 | description: "Map of path aliases to their actual paths (e.g., {'$lib': 'src/lib'})", 31 | }, 32 | }, 33 | additionalProperties: false, 34 | }, 35 | ], 36 | messages: { 37 | missingExtension: "Import should include a file extension", 38 | noFileFound: "Import is missing extension and no matching file was found", 39 | }, 40 | }, 41 | create(context) { 42 | const options = context.options[0] || {}; 43 | const ignorePaths = options.ignorePaths || []; 44 | const includePaths = options.includePaths || []; 45 | const tsToJs = options.tsToJs !== undefined ? options.tsToJs : true; // Default to true 46 | const aliases = options.aliases || {}; 47 | 48 | // Get the project root directory 49 | const projectRoot = process.cwd(); 50 | 51 | // Utility function to resolve file paths 52 | function resolveImportPath(importPath, currentFilePath) { 53 | // Handle relative paths 54 | if (importPath.startsWith("./") || importPath.startsWith("../")) { 55 | return path.resolve(path.dirname(currentFilePath), importPath); 56 | } 57 | 58 | // Handle aliased paths 59 | for (const [alias, aliasPath] of Object.entries(aliases)) { 60 | // Check if the import starts with this alias 61 | if (importPath === alias || importPath.startsWith(`${alias}/`)) { 62 | // Replace the alias with the actual path 63 | const relativePath = importPath === alias ? "" : importPath.slice(alias.length + 1); // +1 for the slash 64 | 65 | // Convert the aliasPath to an absolute path 66 | let absoluteAliasPath = aliasPath; 67 | if (!path.isAbsolute(absoluteAliasPath)) { 68 | absoluteAliasPath = path.resolve(projectRoot, aliasPath); 69 | } 70 | 71 | return path.join(absoluteAliasPath, relativePath); 72 | } 73 | } 74 | 75 | return null; 76 | } 77 | 78 | // Find the file extension by checking which file exists 79 | function findActualFile(basePath) { 80 | if (!basePath) return null; 81 | 82 | try { 83 | // Get the directory and base name 84 | const dir = path.dirname(basePath); 85 | const base = path.basename(basePath); 86 | 87 | // If the directory doesn't exist, return early 88 | if (!fs.existsSync(dir)) { 89 | return null; 90 | } 91 | 92 | // Read all files in the directory 93 | const files = fs.readdirSync(dir); 94 | 95 | // Look for files that match our base name plus any extension 96 | for (const file of files) { 97 | const fileParts = path.parse(file); 98 | 99 | // If we find a file that matches our base name 100 | if (fileParts.name === base) { 101 | // Handle TypeScript to JavaScript conversion 102 | if (tsToJs && fileParts.ext === ".ts") { 103 | return { 104 | actualPath: path.join(dir, file), 105 | importExt: ".js", // Import as .js even though it's a .ts file 106 | }; 107 | } 108 | 109 | // Otherwise use the actual extension 110 | return { 111 | actualPath: path.join(dir, file), 112 | importExt: fileParts.ext, 113 | }; 114 | } 115 | } 116 | } catch (error) { 117 | // If there's an error checking file existence, return null 118 | console.error("Error checking files:", error); 119 | } 120 | 121 | return null; 122 | } 123 | 124 | return { 125 | ImportDeclaration(node) { 126 | const source = node.source.value; 127 | 128 | // Check if it's a relative import or matches a manually specified include path 129 | const isRelativeImport = source.startsWith("./") || source.startsWith("../"); 130 | const isAliasedPath = Object.keys(aliases).some(alias => source === alias || source.startsWith(`${alias}/`)); 131 | const isIncludedPath = includePaths.some(pattern => source.startsWith(pattern)); 132 | 133 | // Skip if it's not a relative import, aliased path, or included path 134 | if (!isRelativeImport && !isAliasedPath && !isIncludedPath) { 135 | return; 136 | } 137 | 138 | // Skip ignored paths 139 | if (ignorePaths.some(path => source.includes(path))) { 140 | return; 141 | } 142 | 143 | // Check if the import already has an extension 144 | const hasExtension = path.extname(source) !== ""; 145 | if (!hasExtension) { 146 | // Get current file path to resolve the import 147 | const currentFilePath = context.getFilename(); 148 | 149 | // Try to determine the correct file by checking what exists 150 | const resolvedPath = resolveImportPath(source, currentFilePath); 151 | const fileInfo = findActualFile(resolvedPath); 152 | 153 | context.report({ 154 | node, 155 | messageId: fileInfo ? "missingExtension" : "noFileFound", 156 | fix(fixer) { 157 | // Only provide a fix if we found a file 158 | if (fileInfo) { 159 | // Replace the string literal with one that includes the extension 160 | return fixer.replaceText(node.source, `"${source}${fileInfo.importExt}"`); 161 | } 162 | 163 | // Otherwise, don't try to fix 164 | return null; 165 | }, 166 | }); 167 | } 168 | }, 169 | }; 170 | }, 171 | }; 172 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import svelte from "eslint-plugin-svelte"; 3 | import globals from "globals"; 4 | import ts from "typescript-eslint"; 5 | import svelteConfig from "./svelte.config.js"; 6 | import enforceExt from "./eslint-rules/enforce-extensions.js"; 7 | import prettier from "eslint-plugin-prettier/recommended"; 8 | 9 | export default ts.config( 10 | js.configs.recommended, 11 | ts.configs.recommended, 12 | ...svelte.configs.recommended, 13 | prettier, 14 | // Configure svelte 15 | { 16 | files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], 17 | // See more details at: https://typescript-eslint.io/packages/parser/ 18 | languageOptions: { 19 | parserOptions: { 20 | projectService: true, 21 | extraFileExtensions: [".svelte"], // Add support for additional file extensions, such as .svelte 22 | parser: ts.parser, 23 | // Specify a parser for each language, if needed: 24 | // parser: { 25 | // ts: ts.parser, 26 | // js: espree, // Use espree for .js files (add: import espree from 'espree') 27 | // typescript: ts.parser 28 | // }, 29 | 30 | // We recommend importing and specifying svelte.config.js. 31 | // By doing so, some rules in eslint-plugin-svelte will automatically read the configuration and adjust their behavior accordingly. 32 | // While certain Svelte settings may be statically loaded from svelte.config.js even if you don’t specify it, 33 | // explicitly specifying it ensures better compatibility and functionality. 34 | svelteConfig, 35 | }, 36 | }, 37 | }, 38 | { 39 | plugins: { 40 | local: { 41 | rules: { 42 | "enforce-ext": enforceExt, 43 | }, 44 | }, 45 | }, 46 | }, 47 | { 48 | rules: { 49 | "require-yield": "off", 50 | "@typescript-eslint/no-explicit-any": "error", 51 | "@typescript-eslint/no-unused-expressions": "off", 52 | // "@typescript-eslint/no-non-null-assertion": "error", 53 | 54 | "@typescript-eslint/no-unused-vars": [ 55 | "error", 56 | { 57 | argsIgnorePattern: "^_", 58 | }, 59 | ], 60 | 61 | "object-shorthand": ["error", "always"], 62 | "svelte/no-at-html-tags": "off", 63 | "svelte/require-each-key": "off", 64 | "local/enforce-ext": [ 65 | "error", 66 | { 67 | includePaths: ["$lib/"], 68 | aliases: { 69 | $lib: "src/lib", 70 | }, 71 | }, 72 | ], 73 | }, 74 | }, 75 | { 76 | ignores: [ 77 | "**/*.cjs", 78 | "**/.DS_Store", 79 | "**/node_modules", 80 | "build", 81 | ".svelte-kit", 82 | "package", 83 | "**/.env", 84 | "**/.env.*", 85 | "!**/.env.example", 86 | "**/pnpm-lock.yaml", 87 | "**/package-lock.json", 88 | "**/yarn.lock", 89 | "context_length.json", 90 | ], 91 | }, 92 | { 93 | languageOptions: { 94 | globals: { 95 | ...globals.browser, 96 | ...globals.node, 97 | }, 98 | }, 99 | } 100 | ); 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inference-playground", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "pnpm run update-ctx-length && vite dev", 7 | "build": "pnpm run update-ctx-length && vite build", 8 | "preview": "vite preview", 9 | "prepare": "ts-patch install && svelte-kit sync || echo ''", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "lint": "prettier . --check . && eslint src/", 13 | "format": "prettier . --write .", 14 | "clean": "rm -rf ./node_modules/ && rm -rf ./.svelte-kit/ && ni && echo 'Project cleaned!'", 15 | "update-ctx-length": "jiti scripts/update-ctx-length.ts", 16 | "test:unit": "vitest", 17 | "test": "npm run test:unit -- --run && npm run test:e2e", 18 | "test:e2e": "playwright test" 19 | }, 20 | "devDependencies": { 21 | "@eslint/eslintrc": "^3.3.0", 22 | "@eslint/js": "^9.22.0", 23 | "@floating-ui/dom": "^1.6.13", 24 | "@huggingface/hub": "^2.1.0", 25 | "@huggingface/inference": "^3.13.2", 26 | "@huggingface/tasks": "^0.19.8", 27 | "@huggingface/transformers": "^3.5.1", 28 | "@iconify-json/carbon": "^1.2.8", 29 | "@iconify-json/material-symbols": "^1.2.15", 30 | "@playwright/test": "^1.49.1", 31 | "@ryoppippi/unplugin-typia": "^1.0.0", 32 | "@samchon/openapi": "^3.0.0", 33 | "@sveltejs/adapter-auto": "^3.2.2", 34 | "@sveltejs/adapter-node": "^5.2.0", 35 | "@sveltejs/kit": "^2.5.27", 36 | "@sveltejs/vite-plugin-svelte": "^4.0.0", 37 | "@tailwindcss/container-queries": "^0.1.1", 38 | "@tailwindcss/postcss": "^4.0.9", 39 | "@testing-library/jest-dom": "^6.6.3", 40 | "@testing-library/svelte": "^5.2.4", 41 | "@types/node": "^22.14.1", 42 | "@vitest/browser": "^3.1.4", 43 | "clsx": "^2.1.1", 44 | "dotenv": "^16.5.0", 45 | "eslint": "^9.22.0", 46 | "eslint-config-prettier": "^10.1.1", 47 | "eslint-plugin-prettier": "^5.2.3", 48 | "fake-indexeddb": "^6.0.1", 49 | "globals": "^16.0.0", 50 | "highlight.js": "^11.10.0", 51 | "jiti": "^2.4.2", 52 | "jsdom": "^26.0.0", 53 | "melt": "^0.30.1", 54 | "openai": "^4.90.0", 55 | "playwright": "^1.52.0", 56 | "postcss": "^8.4.38", 57 | "prettier": "^3.1.1", 58 | "prettier-plugin-svelte": "^3.4.0", 59 | "prettier-plugin-tailwindcss": "^0.6.11", 60 | "runed": "^0.25.0", 61 | "shiki": "^3.4.0", 62 | "svelte": "^5.30.1", 63 | "svelte-check": "^4.0.0", 64 | "tailwind-merge": "^3.0.2", 65 | "tailwindcss": "^4.0.9", 66 | "ts-patch": "^3.3.0", 67 | "tslib": "^2.4.1", 68 | "typescript": "^5.8.2", 69 | "typescript-eslint": "^8.26.1", 70 | "unplugin-icons": "^22.1.0", 71 | "vite": "^5.4.4", 72 | "vitest": "^3.0.0", 73 | "vitest-browser-svelte": "^0.1.0" 74 | }, 75 | "type": "module", 76 | "dependencies": { 77 | "dequal": "^2.0.3", 78 | "eslint-plugin-svelte": "^3.6.0", 79 | "remult": "^3.0.2", 80 | "typia": "^8.0.0" 81 | }, 82 | "pnpm": { 83 | "onlyBuiltDependencies": [ 84 | "esbuild" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | webServer: { 5 | command: process.env.CI ? "npm run build && npm run preview" : "", 6 | port: process.env.CI ? 4173 : 5173, 7 | reuseExistingServer: !process.env.CI, 8 | timeout: 1000 * 60 * 10, 9 | }, 10 | testDir: "e2e", 11 | }); 12 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /scripts/setup-playwright-arch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo ln /usr/lib/libpcre.so.1 /usr/lib/libpcre.so.3 4 | 5 | git clone https://github.com/festvox/flite.git 6 | cd flite 7 | ./configure --enable-shared 8 | make 9 | make get_voices 10 | 11 | sudo cp build/x86_64-linux-gnu/lib/libflite.so.1 /usr/lib 12 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_grapheme_lang.so.1 /usr/lib 13 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_grapheme_lex.so.1 /usr/lib 14 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_indic_lang.so.1 /usr/lib 15 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_indic_lex.so.1 /usr/lib 16 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_time_awb.so.1 /usr/lib 17 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_us_awb.so.1 /usr/lib 18 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_us_kal.so.1 /usr/lib 19 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_us_kal16.so.1 /usr/lib 20 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_us_rms.so.1 /usr/lib 21 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmu_us_slt.so.1 /usr/lib 22 | sudo cp build/x86_64-linux-gnu/lib/libflite_cmulex.so.1 /usr/lib 23 | sudo cp build/x86_64-linux-gnu/lib/libflite_usenglish.so.1 /usr/lib 24 | -------------------------------------------------------------------------------- /scripts/update-ctx-length.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | dotenv.config(); // Load .env file into process.env 3 | 4 | import { fetchAllProviderData, type ApiKeys } from "../src/lib/server/providers/index.js"; // Import ApiKeys type 5 | import fs from "fs/promises"; 6 | import path from "path"; 7 | 8 | const CACHE_FILE_PATH = path.resolve("src/lib/data/context_length.json"); 9 | 10 | async function runUpdate() { 11 | console.log("Starting context length cache update..."); 12 | 13 | // Gather API keys from process.env 14 | const apiKeys: ApiKeys = { 15 | COHERE_API_KEY: process.env.COHERE_API_KEY, 16 | TOGETHER_API_KEY: process.env.TOGETHER_API_KEY, 17 | FIREWORKS_API_KEY: process.env.FIREWORKS_API_KEY, 18 | HYPERBOLIC_API_KEY: process.env.HYPERBOLIC_API_KEY, 19 | REPLICATE_API_KEY: process.env.REPLICATE_API_KEY, 20 | NEBIUS_API_KEY: process.env.NEBIUS_API_KEY, 21 | NOVITA_API_KEY: process.env.NOVITA_API_KEY, 22 | SAMBANOVA_API_KEY: process.env.SAMBANOVA_API_KEY, 23 | }; 24 | 25 | try { 26 | // Fetch data from all supported providers concurrently, passing keys 27 | const fetchedData = await fetchAllProviderData(apiKeys); 28 | 29 | // Read existing manual/cached data 30 | let existingData = {}; 31 | try { 32 | const currentCache = await fs.readFile(CACHE_FILE_PATH, "utf-8"); 33 | existingData = JSON.parse(currentCache); 34 | } catch { 35 | // Remove unused variable name 36 | console.log("No existing cache file found or error reading, creating new one."); 37 | } 38 | 39 | // Merge fetched data with existing data (fetched data takes precedence) 40 | const combinedData = { ...existingData, ...fetchedData }; 41 | 42 | // Write the combined data back to the file 43 | const tempFilePath = CACHE_FILE_PATH + ".tmp"; 44 | await fs.writeFile(tempFilePath, JSON.stringify(combinedData, null, "\t"), "utf-8"); 45 | await fs.rename(tempFilePath, CACHE_FILE_PATH); 46 | 47 | console.log("Context length cache update complete."); 48 | console.log(`Cache file written to: ${CACHE_FILE_PATH}`); 49 | } catch (error) { 50 | console.error("Error during context length cache update:", error); 51 | process.exit(1); // Exit with error code 52 | } 53 | } 54 | 55 | runUpdate(); 56 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import "highlight.js/styles/atom-one-light" layer(base); 2 | @import "tailwindcss"; 3 | 4 | @plugin '@tailwindcss/container-queries'; 5 | 6 | @custom-variant dark (&:where(.dark, .dark *)); 7 | 8 | /* 9 | The default border color has changed to `currentColor` in Tailwind CSS v4, 10 | so we've added these compatibility styles to make sure everything still 11 | looks the same as it did with Tailwind CSS v3. 12 | 13 | If we ever want to remove these styles, we need to add an explicit border 14 | color utility to any element that depends on these defaults. 15 | */ 16 | @layer base { 17 | *, 18 | ::after, 19 | ::before, 20 | ::backdrop, 21 | ::file-selector-button { 22 | border-color: var(--color-gray-200, currentColor); 23 | } 24 | } 25 | 26 | /* Theme config */ 27 | @theme { 28 | --text-2xs: 0.625rem; 29 | --text-3xs: 0.5rem; 30 | 31 | --animate-fade-in: fade-in 0.15s ease; 32 | @keyframes fade-in { 33 | 0% { 34 | opacity: 0; 35 | /* scale: 0.99; */ 36 | } 37 | 100% { 38 | opacity: 1; 39 | scale: 1; 40 | } 41 | } 42 | } 43 | 44 | /* Custom variants */ 45 | @custom-variant nd { 46 | &:not(:disabled) { 47 | @slot; 48 | } 49 | } 50 | 51 | /* Utilities */ 52 | @utility abs-x-center { 53 | left: 50%; 54 | @apply -translate-x-1/2; 55 | } 56 | 57 | @utility abs-y-center { 58 | top: 50%; 59 | @apply -translate-y-1/2; 60 | } 61 | 62 | @utility abs-center { 63 | @apply abs-x-center abs-y-center; 64 | } 65 | 66 | @utility btn { 67 | @apply flex h-[39px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 focus:ring-4 focus:ring-gray-100 focus:outline-hidden dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700; 68 | } 69 | 70 | @utility btn-sm { 71 | @apply flex h-[32px] items-center justify-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-2 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 focus:ring-4 focus:ring-gray-100 focus:outline-hidden dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700; 72 | } 73 | 74 | @utility btn-xs { 75 | @apply flex h-[28px] items-center justify-center gap-1 rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 focus:ring-4 focus:ring-gray-100 focus:outline-hidden dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700; 76 | } 77 | 78 | @utility btn-mini { 79 | @apply flex h-[24px] items-center justify-center gap-0.5 rounded-sm border border-gray-200 bg-white px-1.5 py-1 text-[10px] font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-gray-100 focus:outline-hidden dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700; 80 | } 81 | 82 | @utility custom-outline { 83 | @apply outline-hidden; 84 | @apply border-blue-500 ring ring-blue-500; 85 | } 86 | 87 | @utility focus-outline { 88 | @apply focus-visible:custom-outline; 89 | } 90 | 91 | @utility input { 92 | @apply rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400; 93 | @apply focus-outline; 94 | } 95 | 96 | /** utility that adds a fade on top and bottom using clip-path or something similar */ 97 | @utility fade-y { 98 | --start: 2.5%; 99 | --end: calc(100% - var(--start)); 100 | -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--start), black var(--end), transparent); 101 | mask-image: linear-gradient(to bottom, transparent, black var(--start), black var(--end), transparent); 102 | } 103 | 104 | /* Elements & Classes */ 105 | html { 106 | font-size: 15px; 107 | } 108 | 109 | body { 110 | overflow: hidden; 111 | } 112 | 113 | body.dark { 114 | color-scheme: dark; 115 | } 116 | 117 | @layer base { 118 | :focus-visible { 119 | outline: 3px solid var(--color-blue-400); 120 | outline-offset: 2px; 121 | 122 | @variant dark { 123 | outline-color: var(--color-blue-500); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | import "unplugin-icons/types/svelte"; 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | declare global { 6 | namespace App { 7 | // interface Error {} 8 | // interface Locals {} 9 | // interface PageData {} 10 | // interface PageState {} 11 | // interface Platform {} 12 | } 13 | } 14 | 15 | export {}; 16 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hugging Face Playground 8 | %sveltekit.head% 9 | 10 | 11 | 12 | 20 | 51 | 52 |
%sveltekit.body%
53 | 54 | 55 | -------------------------------------------------------------------------------- /src/lib/attachments/autofocus.ts: -------------------------------------------------------------------------------- 1 | import { tick } from "svelte"; 2 | import type { Attachment } from "svelte/attachments"; 3 | 4 | export function autofocus(enabled = true): Attachment { 5 | return node => { 6 | if (!enabled) return; 7 | 8 | tick().then(() => { 9 | node.focus(); 10 | }); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/attachments/click-outside.ts: -------------------------------------------------------------------------------- 1 | import type { Attachment } from "svelte/attachments"; 2 | 3 | export function clickOutside(callback: () => void): Attachment { 4 | return node => { 5 | function handleClick(event: MouseEvent) { 6 | if (window.getSelection()?.toString()) { 7 | // Don't close if text is selected 8 | return; 9 | } 10 | 11 | // For dialog elements, check if click was on the backdrop 12 | if (node instanceof HTMLDialogElement) { 13 | const rect = node.getBoundingClientRect(); 14 | const isInDialog = 15 | event.clientX >= rect.left && 16 | event.clientX <= rect.right && 17 | event.clientY >= rect.top && 18 | event.clientY <= rect.bottom; 19 | 20 | if (!isInDialog) { 21 | callback(); 22 | return; 23 | } 24 | } 25 | 26 | // For non-dialog elements, use the standard contains check 27 | if (!node.contains(event.target as Node) && !event.defaultPrevented) { 28 | callback(); 29 | } 30 | } 31 | 32 | // For dialogs, listen on the element itself 33 | if (node instanceof HTMLDialogElement) { 34 | node.addEventListener("click", handleClick); 35 | } else { 36 | // For other elements, listen on the document 37 | document.addEventListener("click", handleClick, true); 38 | } 39 | 40 | return () => { 41 | if (node instanceof HTMLDialogElement) { 42 | node.removeEventListener("click", handleClick); 43 | } else { 44 | document.removeEventListener("click", handleClick, true); 45 | } 46 | }; 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/attachments/observe.svelte.ts: -------------------------------------------------------------------------------- 1 | import { AnimationFrames } from "runed"; 2 | import type { Attachment } from "svelte/attachments"; 3 | 4 | export enum ObservedElements { 5 | BottomActions = "bottom-actions", 6 | TokenCountEnd = "token-count-end", 7 | TokenCountStart = "token-count-start", 8 | // Add other elements here as needed 9 | } 10 | 11 | type ObservedData = { 12 | rect: { 13 | width: number; 14 | height: number; 15 | top: number; 16 | left: number; 17 | right: number; 18 | bottom: number; 19 | }; 20 | offset: { 21 | width: number; 22 | height: number; 23 | top: number; 24 | left: number; 25 | right: number; 26 | bottom: number; 27 | }; 28 | }; 29 | 30 | export const observed: Record = $state( 31 | Object.values(ObservedElements).reduce( 32 | (acc, key) => { 33 | acc[key] = { 34 | rect: { 35 | width: 0, 36 | height: 0, 37 | top: 0, 38 | left: 0, 39 | right: 0, 40 | bottom: 0, 41 | }, 42 | offset: { 43 | width: 0, 44 | height: 0, 45 | top: 0, 46 | left: 0, 47 | right: 0, 48 | bottom: 0, 49 | }, 50 | }; 51 | return acc; 52 | }, 53 | {} as Record 54 | ) 55 | ); 56 | 57 | type ObserveArgs = { 58 | name: ObservedElements; 59 | useRaf?: boolean; 60 | }; 61 | 62 | function getOffsetPosition(el: HTMLElement) { 63 | let top = 0; 64 | let left = 0; 65 | const width = el.offsetWidth; 66 | const height = el.offsetHeight; 67 | 68 | while (el) { 69 | top += el.offsetTop; 70 | left += el.offsetLeft; 71 | el = el.offsetParent as HTMLElement; 72 | } 73 | 74 | return { top, left, width, height, right: left + width, bottom: top + height }; 75 | } 76 | 77 | export function observe(args: ObserveArgs): Attachment { 78 | return node => { 79 | function setVars(name: ObservedElements) { 80 | // 1. Standard rect (includes transforms) 81 | const rect = node.getBoundingClientRect(); 82 | document.documentElement.style.setProperty(`--${name}-width`, `${rect.width}px`); 83 | document.documentElement.style.setProperty(`--${name}-height`, `${rect.height}px`); 84 | document.documentElement.style.setProperty(`--${name}-top`, `${rect.top}px`); 85 | document.documentElement.style.setProperty(`--${name}-left`, `${rect.left}px`); 86 | document.documentElement.style.setProperty(`--${name}-right`, `${rect.right}px`); 87 | document.documentElement.style.setProperty(`--${name}-bottom`, `${rect.bottom}px`); 88 | 89 | // 2. Offset position (ignores transforms) 90 | const offset = getOffsetPosition(node); 91 | document.documentElement.style.setProperty(`--${name}-width-offset`, `${offset.width}px`); 92 | document.documentElement.style.setProperty(`--${name}-height-offset`, `${offset.height}px`); 93 | document.documentElement.style.setProperty(`--${name}-top-offset`, `${offset.top}px`); 94 | document.documentElement.style.setProperty(`--${name}-left-offset`, `${offset.left}px`); 95 | document.documentElement.style.setProperty(`--${name}-right-offset`, `${offset.right}px`); 96 | document.documentElement.style.setProperty(`--${name}-bottom-offset`, `${offset.bottom}px`); 97 | 98 | observed[name] = { 99 | rect, 100 | offset, 101 | }; 102 | } 103 | 104 | function onWindowChange() { 105 | setVars(args.name); 106 | } 107 | 108 | /** Initialize */ 109 | const resizeObserver = new ResizeObserver(() => { 110 | setVars(args.name); 111 | }); 112 | 113 | resizeObserver.observe(node); 114 | 115 | // Listen for scroll and resize events 116 | window.addEventListener("scroll", onWindowChange, true); 117 | window.addEventListener("resize", onWindowChange, true); 118 | 119 | setVars(args.name); // Initial set after observing 120 | if (args.useRaf) { 121 | new AnimationFrames(() => { 122 | setVars(args.name); 123 | }); 124 | } 125 | 126 | return function destroy() { 127 | resizeObserver.disconnect(); 128 | window.removeEventListener("scroll", onWindowChange, true); 129 | window.removeEventListener("resize", onWindowChange, true); 130 | }; 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/components/avatar.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | {#if isCustom} 32 |
35 | 36 |
37 | {:else} 38 | {#await getAvatarUrl(_orgName)} 39 |
40 | {:then avatarUrl} 41 | {#if avatarUrl} 42 | {_orgName} avatar 43 | {:else} 44 |
45 | {/if} 46 | {:catch} 47 |
48 | {/await} 49 | {/if} 50 | -------------------------------------------------------------------------------- /src/lib/components/debug-menu.svelte: -------------------------------------------------------------------------------- 1 | 98 | 99 | 100 | 101 | {#if dev} 102 |
103 | 106 | 107 |
111 |

Debug Menu

112 | 113 |
114 |
115 |

Viewport: {innerWidth}x{innerHeight}

116 |

Environment: {import.meta.env.MODE}

117 |

isDark: {isDark()}

118 |
119 | 120 |
121 | {#each actions as { label, cb }} 122 | 128 | {/each} 129 |
130 |
131 |
132 |
133 | {/if} 134 | 135 | 138 | -------------------------------------------------------------------------------- /src/lib/components/dialog.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | {#if open} 32 |
33 |
onClose?.())} 36 | > 37 |
38 | {#if typeof title === "string"} 39 |

40 | {title} 41 |

42 | {:else} 43 | {@render title()} 44 | {/if} 45 | 55 |
56 | 57 |
58 | {@render children()} 59 |
60 | 61 | {#if footer} 62 | 63 |
64 | {@render footer()} 65 |
66 | {/if} 67 |
68 |
69 | {/if} 70 |
71 | -------------------------------------------------------------------------------- /src/lib/components/icon-custom.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if icon === "regen"} 14 | 15 | 19 | 20 | {/if} 21 | 22 | {#if icon === "refresh"} 23 | 29 | {/if} 30 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/checkpoints-menu.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | 37 | 38 | (popover.open = false))} 42 | {...popover.content} 43 | data-test-id={TEST_IDS.checkpoints_menu} 44 | > 45 |
49 |
50 |
51 |

Checkpoints

52 | 58 |
59 | 60 | {#each projCheckpoints as checkpoint (checkpoint.id)} 61 | {@const conversations = checkpoint.conversations} 62 | {@const multiple = conversations.length > 1} 63 | 75 | {#snippet children(tooltip)} 76 |
81 | 104 | 105 | 118 | 127 |
128 | 129 | {#if tooltip.open} 130 |
137 |
141 | {#each conversations as conversation, i} 142 | {@const msgs = conversation.messages} 143 | {@const sliced = msgs.slice(0, 4)} 144 |
151 |

152 | temp: {conversation.config.temperature} 153 | {#if conversation.config.max_tokens} 154 | | max tokens: {conversation.config.max_tokens} 155 | {/if} 156 | {#if conversation.structuredOutput?.enabled} 157 | | structured output 158 | {/if} 159 |

160 | {#each iterate(sliced) as [msg, isLast]} 161 |
162 |

{msg.role}

163 | {#if msg.content?.trim()} 164 |

{msg.content.trim()}

165 | {:else} 166 |

No content

167 | {/if} 168 |
169 | {#if !isLast} 170 |
171 | {/if} 172 | {/each} 173 |
174 | {/each} 175 |
176 | {/if} 177 | {/snippet} 178 |
179 | {:else} 180 |
181 | No checkpoints available 182 |
183 | {/each} 184 |
185 |
186 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/conversation-header.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | {#if modelSelectorOpen} 37 | (modelSelectorOpen = false)} /> 38 | {/if} 39 | 40 |
45 | 46 | 50 | 56 | 65 |
66 | 67 | {#if isHFModel(conversation.model)} 68 |
73 | 74 | 78 |
79 | {/if} 80 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/conversation.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 |
73 | {#if !viewCode} 74 | {#each conversation.data.messages as message, index} 75 | conversation.deleteMessage(index)} 81 | onRegen={() => regenMessage(index)} 82 | /> 83 | {/each} 84 | 85 | 97 | {:else} 98 | 99 | {/if} 100 |
101 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/custom-provider-select.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 | 88 | 89 |
90 | {#each providers as p} 91 |
92 |
95 | 96 | {formatName(p)} 97 |
98 |
99 | {/each} 100 |
101 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/generation-config-settings.ts: -------------------------------------------------------------------------------- 1 | import type { ChatCompletionInput } from "@huggingface/tasks"; 2 | 3 | export const GENERATION_CONFIG_KEYS = ["temperature", "max_tokens", "top_p"] as const; 4 | 5 | export type GenerationConfigKey = (typeof GENERATION_CONFIG_KEYS)[number]; 6 | 7 | export type GenerationConfig = Pick; 8 | 9 | interface GenerationKeySettings { 10 | default?: number; 11 | step: number; 12 | min: number; 13 | max: number; 14 | label: string; 15 | } 16 | 17 | export const GENERATION_CONFIG_SETTINGS: Record = { 18 | temperature: { 19 | default: 0.5, 20 | step: 0.1, 21 | min: 0, 22 | max: 2, 23 | label: "Temperature", 24 | }, 25 | max_tokens: { 26 | step: 256, 27 | min: 0, 28 | max: 8192, // changed dynamically based on model 29 | label: "Max Tokens", 30 | }, 31 | top_p: { 32 | default: 0.7, 33 | step: 0.1, 34 | min: 0, 35 | max: 1, 36 | label: "Top-P", 37 | }, 38 | }; 39 | 40 | export const defaultGenerationConfig = GENERATION_CONFIG_KEYS.reduce((acc, key) => { 41 | acc[key] = GENERATION_CONFIG_SETTINGS[key].default; 42 | return acc; 43 | }, {} as GenerationConfig); 44 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/generation-config.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 |
49 | {#each GENERATION_CONFIG_KEYS as key} 50 | {@const { label, min, step } = GENERATION_CONFIG_SETTINGS[key]} 51 | {@const isMaxTokens = key === "max_tokens"} 52 | {@const max = isMaxTokens ? maxTokens : GENERATION_CONFIG_SETTINGS[key].max} 53 | 54 |
55 |
56 | 59 |
60 | {#if !isMaxTokens || isNumber(conversation.data.config[key])} 61 | conversation.data.config[key], v => updateConfigKey(key, v)} 68 | /> 69 | {/if} 70 | {#if isMaxTokens && isNumber(conversation.data.config[key])} 71 | 72 | {:else if isMaxTokens} 73 | 74 | {/if} 75 |
76 |
77 | {#if !isMaxTokens || isNumber(conversation.data.config[key])} 78 | conversation.data.config[key], v => updateConfigKey(key, v)} 85 | class="h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 accent-black dark:bg-gray-700 dark:accent-blue-500" 86 | /> 87 | {/if} 88 |
89 | {/each} 90 | 91 | 102 | 103 | 104 | {#if !structuredForbiddenProviders.includes(conversation.data.provider as any)} 105 | 124 | {/if} 125 |
126 | 127 | 128 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/hf-token-modal.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 122 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/img-preview.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | { 27 | e.preventDefault(); 28 | img = undefined; 29 | }} 30 | > 31 | {#if img} 32 | 33 |
37 | 38 | (img = undefined))} 43 | transition:scale={{ start: 0.975, duration: 250 }} 44 | /> 45 | 46 | 56 |
57 | {/if} 58 |
59 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/model-selector-modal.svelte: -------------------------------------------------------------------------------- 1 | 71 | 72 | 73 | 74 |
75 |
78 |
79 |
80 | 81 |
82 | 89 |
90 |
95 | {#snippet modelEntry(model: Model | CustomModel, trending?: boolean)} 96 | {@const [nameSpace, modelName] = model.id.split("/")} 97 |
103 | {#if trending} 104 |
105 | 106 |
107 | {/if} 108 | 109 | {#if modelName} 110 | 111 | {nameSpace} 112 | / 113 | {modelName} 114 | 115 | {:else} 116 | {nameSpace} 117 | {/if} 118 | 119 | {#if "pipeline_tag" in model && model.pipeline_tag === "image-text-to-text"} 120 | 121 | {#snippet trigger(tooltip)} 122 |
126 | 127 |
128 | {/snippet} 129 | Image text-to-text 130 |
131 | {/if} 132 | 133 | {#if isCustom(model)} 134 | 135 | {#snippet trigger(tooltip)} 136 |
140 | 141 |
142 | {/snippet} 143 | Custom Model 144 |
145 | 146 | {#snippet trigger(tooltip)} 147 | 165 | {/snippet} 166 | Edit 167 | 168 | {/if} 169 |
170 | {/snippet} 171 | {#if trending.length > 0} 172 |
Trending
173 | {#each trending as model} 174 | {@render modelEntry(model, true)} 175 | {/each} 176 | {/if} 177 |
Custom endpoints
178 | {#if custom.length > 0} 179 | {#each custom as model} 180 | {@render modelEntry(model, false)} 181 | {/each} 182 | {/if} 183 |
{ 186 | onClose?.(); 187 | openCustomModelConfig({ 188 | onSubmit: model => { 189 | onModelSelect?.(model.id); 190 | }, 191 | }); 192 | })} 193 | > 194 | 195 | Add a custom endpoint 196 |
197 | {#if other.length > 0} 198 |
Other models
199 | {#each other as model} 200 | {@render modelEntry(model, false)} 201 | {/each} 202 | {/if} 203 |
204 |
205 |
206 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/model-selector.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 | 41 | 59 |
60 | 61 | {#if showModelPickerModal} 62 | (showModelPickerModal = false)} /> 63 | {/if} 64 | 65 | {#if isHFModel(conversation.model)} 66 | 67 | 68 | {/if} 69 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/project-select.svelte: -------------------------------------------------------------------------------- 1 | 64 | 65 |
66 | 82 | 83 |
84 | 85 | {#if isDefault} 86 | 87 | {#snippet trigger(tooltip)} 88 | 91 | {/snippet} 92 | Save as project 93 | 94 | {:else} 95 | 96 | {#snippet trigger(tooltip)} 97 | 100 | {/snippet} 101 | Close project 102 | 103 | {/if} 104 |
105 |
106 | 107 |
108 | {#each projects.all as { name, id } (id)} 109 | {@const option = select.getOption(id)} 110 | {@const hasCheckpoints = checkpoints.for(id).length > 0} 111 |
112 |
115 |
116 | {name} 117 | {#if hasCheckpoints} 118 |
122 | 123 |
124 | {/if} 125 |
126 | {#if id !== "default"} 127 |
128 | 137 | 146 |
147 | {/if} 148 |
149 |
150 | {/each} 151 |
152 | 153 | (sdState.open = false)} onSubmit={saveDialog}> 154 | 164 | 168 | 169 | {#snippet footer()} 170 | 176 | {/snippet} 177 | 178 | -------------------------------------------------------------------------------- /src/lib/components/inference-playground/provider-select.svelte: -------------------------------------------------------------------------------- 1 | 74 | 75 |
76 | 81 | 82 | 100 | 101 |
102 | {#each conversation.model.inferenceProviderMapping as { provider, providerId } (provider + providerId)} 103 |
104 |
107 | 108 | {formatName(provider)} 109 |
110 |
111 | {/each} 112 |
113 |
114 | -------------------------------------------------------------------------------- /src/lib/components/label-pro.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | PRO 23 | 24 | -------------------------------------------------------------------------------- /src/lib/components/local-toasts.svelte: -------------------------------------------------------------------------------- 1 | 131 | 132 | {@render children({ trigger, addToast: toaster.addToast })} 133 | 134 | {#each toaster.toasts.slice(toaster.toasts.length - 1) as toast (toast.id)} 135 |
141 | {#if toastSnippet} 142 | {@render toastSnippet({ toast, float })} 143 | {:else} 144 | {toast.data.content} 145 | {/if} 146 |
147 | {/each} 148 | 149 | 160 | -------------------------------------------------------------------------------- /src/lib/components/prompts.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 45 | 46 | 47 | {#if current} 48 |
49 |
54 |
55 |

56 | {current.label ?? "Prompt"} 57 |

58 | 68 |
69 | 70 |
71 | 81 |
82 | 83 | 84 |
85 | 90 |
91 |
92 |
93 | {/if} 94 |
95 | -------------------------------------------------------------------------------- /src/lib/components/quota-modal.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 33 | 34 | (open = false)}> 35 | {#if open} 36 | 37 |
41 | 42 |
(open = false))} 45 | transition:scale={{ start: 0.975, duration: 250 }} 46 | > 47 |

48 | Upgrade Your AI Experience with a Account 51 |

52 | 62 | 63 | 64 |
65 |

68 | You have reached your usage limits. To continue using the playground, please consider creating a PRO 69 | account! 70 |

71 |

72 | By subscribing to PRO, you get $2 worth of Inference credits every month. Meaning you could: 75 |

76 |
    77 | {#each actions as action} 78 |
  • 79 |
    82 | 83 |
    84 | {action} 85 |
  • 86 | {/each} 87 |
88 |
89 | 90 | 91 | 103 |
104 |
105 | {/if} 106 |
107 | -------------------------------------------------------------------------------- /src/lib/components/toaster.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
53 | {#each toaster.toasts as toast, i (toast.id)} 54 |
62 |

63 | {toast.data.title} 64 |

65 | 66 | {#if toast.data.description} 67 |

68 | {toast.data.description} 69 |

70 | {/if} 71 | 72 | 79 | 80 | {#if toast.closeDelay !== 0} 81 |
82 | 83 | {#snippet children(progress)} 84 |
85 |
92 |
93 | {/snippet} 94 |
95 |
96 | {/if} 97 |
98 | {/each} 99 |
100 | 101 | 171 | -------------------------------------------------------------------------------- /src/lib/components/toaster.svelte.ts: -------------------------------------------------------------------------------- 1 | import { Toaster } from "melt/builders"; 2 | 3 | export type ToastData = { 4 | title: string; 5 | description: string; 6 | variant: "success" | "warning" | "error"; 7 | }; 8 | 9 | export const toaster = new Toaster({ 10 | hover: "pause-all", 11 | // closeDelay: 0, 12 | }); 13 | 14 | export function addToast(data: ToastData) { 15 | toaster.addToast({ data }); 16 | } 17 | 18 | export function removeToast(id: string) { 19 | toaster.removeToast(id); 20 | } 21 | 22 | // Debugging 23 | // addToast({ 24 | // title: "Hello World 1", 25 | // description: "hey", 26 | // variant: "success", 27 | // }); 28 | // 29 | // addToast({ 30 | // title: "Hello World 2", 31 | // description: "hey", 32 | // variant: "success", 33 | // }); 34 | // 35 | // addToast({ 36 | // title: "Hello World 3", 37 | // description: "hi", 38 | // variant: "success", 39 | // }); 40 | -------------------------------------------------------------------------------- /src/lib/components/tooltip.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | {@render trigger(tooltip)} 38 | 39 |
40 |
41 |

{@render children()}

42 |
43 | 44 | 65 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export enum TEST_IDS { 2 | checkpoints_trigger, 3 | checkpoints_menu, 4 | checkpoint, 5 | 6 | reset, 7 | 8 | message, 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/remult.ts: -------------------------------------------------------------------------------- 1 | import { JsonDataProvider, Remult, remult, type JsonEntityStorage } from "remult"; 2 | import { createSubscriber } from "svelte/reactivity"; 3 | 4 | // To be done once in the application. 5 | export function initRemultSvelteReactivity() { 6 | // Auth reactivity (remult.user, remult.authenticated(), ...) 7 | { 8 | let update = () => {}; 9 | const s = createSubscriber(u => { 10 | update = u; 11 | }); 12 | remult.subscribeAuth({ 13 | reportObserved: () => s(), 14 | reportChanged: () => update(), 15 | }); 16 | } 17 | 18 | // Entities reactivity 19 | { 20 | Remult.entityRefInit = x => { 21 | let update = () => {}; 22 | const s = createSubscriber(u => { 23 | update = u; 24 | }); 25 | x.subscribe({ 26 | reportObserved: () => s(), 27 | reportChanged: () => update(), 28 | }); 29 | }; 30 | } 31 | } 32 | 33 | export class JsonEntityIndexedDbStorage implements JsonEntityStorage { 34 | constructor( 35 | private dbName: string = "db", 36 | private storeName: string = "jsonStore" 37 | ) {} 38 | supportsRawJson = true; 39 | //@internal 40 | db?: IDBDatabase; 41 | async getItem(entityDbName: string) { 42 | // eslint-disable-next-line no-async-promise-executor 43 | return new Promise(async (resolve, reject) => { 44 | const transaction = (await this.init()).transaction([this.storeName], "readonly"); 45 | const store = transaction.objectStore(this.storeName); 46 | const request = store.get(entityDbName); 47 | 48 | request.onerror = _event => reject(request.error); 49 | request.onsuccess = _event => { 50 | if (request.result) { 51 | resolve(request.result); 52 | } else { 53 | resolve(null!); 54 | } 55 | }; 56 | }); 57 | } 58 | //@internal 59 | async init() { 60 | if (!this.db) { 61 | this.db = await new Promise((resolve, reject) => { 62 | let db: IDBDatabase; 63 | const request = indexedDB.open(this.dbName, 1); 64 | 65 | request.onerror = _event => reject(request.error); 66 | 67 | request.onsuccess = _event => { 68 | db = request.result; 69 | resolve(db); 70 | }; 71 | 72 | request.onupgradeneeded = _event => { 73 | db = request.result; 74 | db.createObjectStore(this.storeName); 75 | }; 76 | }); 77 | } 78 | return this.db; 79 | } 80 | 81 | async setItem(entityDbName: string, json: string) { 82 | // eslint-disable-next-line no-async-promise-executor 83 | return new Promise(async (resolve, reject) => { 84 | const transaction = (await this.init()).transaction([this.storeName], "readwrite"); 85 | const store = transaction.objectStore(this.storeName); 86 | const request = store.put(json, entityDbName); 87 | 88 | request.onerror = _event => reject(request.error); 89 | request.onsuccess = _event => resolve(); 90 | }); 91 | } 92 | 93 | async deleteItem(entityDbName: string) { 94 | // eslint-disable-next-line no-async-promise-executor 95 | return new Promise(async (resolve, reject) => { 96 | const transaction = (await this.init()).transaction([this.storeName], "readwrite"); 97 | const store = transaction.objectStore(this.storeName); 98 | const request = store.delete(entityDbName); 99 | 100 | request.onerror = _event => reject(request.error); 101 | request.onsuccess = _event => resolve(); 102 | }); 103 | } 104 | } 105 | 106 | export const idb = new JsonDataProvider(new JsonEntityIndexedDbStorage()); 107 | -------------------------------------------------------------------------------- /src/lib/server/api.ts: -------------------------------------------------------------------------------- 1 | import { remultApi } from "remult/remult-sveltekit"; 2 | 3 | export const api = remultApi({}); 4 | -------------------------------------------------------------------------------- /src/lib/server/providers/cohere.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const COHERE_API_URL = "https://api.cohere.ai/v1/models"; 4 | 5 | // Accept apiKey as an argument 6 | export async function fetchCohereData(apiKey: string | undefined): Promise { 7 | if (!apiKey) { 8 | console.warn("Cohere API key not provided. Skipping Cohere fetch."); 9 | return {}; 10 | } 11 | try { 12 | const response = await fetch(COHERE_API_URL, { 13 | headers: { 14 | Authorization: `Bearer ${apiKey}`, // Use passed-in apiKey 15 | }, 16 | }); 17 | if (!response.ok) { 18 | throw new Error(`Cohere API request failed: ${response.status} ${response.statusText}`); 19 | } 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | const data: any = await response.json(); 22 | const modelsData: MaxTokensCache["cohere"] = {}; 23 | if (data?.models && Array.isArray(data.models)) { 24 | for (const model of data.models) { 25 | if (model.name && typeof model.context_length === "number") { 26 | modelsData[model.name] = model.context_length; 27 | } 28 | } 29 | } 30 | return modelsData; 31 | } catch (error) { 32 | console.error("Error fetching Cohere data:", error); 33 | return {}; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/server/providers/fireworks.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const FIREWORKS_API_URL = "https://api.fireworks.ai/inference/v1/models"; // Assumed 4 | 5 | export async function fetchFireworksData(apiKey: string | undefined): Promise { 6 | if (!apiKey) { 7 | console.warn("Fireworks AI API key not provided. Skipping Fireworks AI fetch."); 8 | return {}; 9 | } 10 | try { 11 | const response = await fetch(FIREWORKS_API_URL, { 12 | headers: { 13 | Authorization: `Bearer ${apiKey}`, 14 | }, 15 | }); 16 | if (!response.ok) { 17 | throw new Error(`Fireworks AI API request failed: ${response.status} ${response.statusText}`); 18 | } 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | const data: any = await response.json(); // Assuming OpenAI structure { data: [ { id: string, ... } ] } 21 | const modelsData: MaxTokensCache["fireworks-ai"] = {}; 22 | 23 | // Check if data and data.data exist and are an array 24 | if (data?.data && Array.isArray(data.data)) { 25 | for (const model of data.data) { 26 | // Check for common context length fields (OpenAI uses context_window) 27 | const contextLength = model.context_length ?? model.context_window ?? model.config?.max_tokens ?? null; 28 | // Fireworks uses model.id 29 | if (model.id && typeof contextLength === "number") { 30 | modelsData[model.id] = contextLength; 31 | } 32 | } 33 | } else { 34 | console.warn("Unexpected response structure from Fireworks AI API:", data); 35 | } 36 | return modelsData; 37 | } catch (error) { 38 | console.error("Error fetching Fireworks AI data:", error); 39 | return {}; // Return empty on error 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/server/providers/hyperbolic.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const HYPERBOLIC_API_URL = "https://api.hyperbolic.xyz/v1/models"; // Assumed 4 | 5 | export async function fetchHyperbolicData(apiKey: string | undefined): Promise { 6 | if (!apiKey) { 7 | console.warn("Hyperbolic API key not provided. Skipping Hyperbolic fetch."); 8 | return {}; 9 | } 10 | try { 11 | const response = await fetch(HYPERBOLIC_API_URL, { 12 | headers: { 13 | Authorization: `Bearer ${apiKey}`, 14 | }, 15 | }); 16 | if (!response.ok) { 17 | throw new Error(`Hyperbolic API request failed: ${response.status} ${response.statusText}`); 18 | } 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | const data: any = await response.json(); // Assuming OpenAI structure { data: [ { id: string, ... } ] } 21 | const modelsData: MaxTokensCache["hyperbolic"] = {}; 22 | 23 | // Check if data and data.data exist and are an array 24 | if (data?.data && Array.isArray(data.data)) { 25 | for (const model of data.data) { 26 | // Check for common context length fields (OpenAI uses context_window) 27 | const contextLength = model.context_length ?? model.context_window ?? model.config?.max_tokens ?? null; 28 | // Assuming Hyperbolic uses model.id 29 | if (model.id && typeof contextLength === "number") { 30 | modelsData[model.id] = contextLength; 31 | } 32 | } 33 | } else { 34 | console.warn("Unexpected response structure from Hyperbolic API:", data); 35 | } 36 | return modelsData; 37 | } catch (error) { 38 | console.error("Error fetching Hyperbolic data:", error); 39 | return {}; // Return empty on error 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/server/providers/nebius.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | interface NebiusModel { 4 | id: string; 5 | config?: { 6 | max_tokens?: number; 7 | }; 8 | context_length?: number; 9 | } 10 | 11 | interface NebiusResponse { 12 | data?: NebiusModel[]; 13 | } 14 | 15 | const NEBIUS_API_URL = "https://api.studio.nebius.com/v1/models?verbose=true"; 16 | 17 | export async function fetchNebiusData(apiKey: string | undefined): Promise { 18 | if (!apiKey) { 19 | console.warn("Nebius API key not provided. Skipping Nebius fetch."); 20 | return {}; 21 | } 22 | try { 23 | const response = await fetch(NEBIUS_API_URL, { 24 | headers: { 25 | Authorization: `Bearer ${apiKey}`, 26 | }, 27 | }); 28 | if (!response.ok) { 29 | throw new Error(`Nebius API request failed: ${response.status} ${response.statusText}`); 30 | } 31 | const data: NebiusResponse = await response.json(); 32 | const modelsData: MaxTokensCache["nebius"] = {}; 33 | 34 | if (data?.data && Array.isArray(data.data)) { 35 | for (const model of data.data) { 36 | const contextLength = model.context_length ?? model.config?.max_tokens ?? null; 37 | if (model.id && typeof contextLength === "number") { 38 | modelsData[model.id] = contextLength; 39 | } 40 | } 41 | } else { 42 | console.warn("Unexpected response structure from Nebius API:", data); 43 | } 44 | return modelsData; 45 | } catch (error) { 46 | console.error("Error fetching Nebius data:", error); 47 | return {}; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/server/providers/novita.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const NOVITA_API_URL = "https://api.novita.ai/v3/openai/models"; 4 | 5 | interface NovitaModel { 6 | id: string; 7 | object: string; 8 | context_size: number; 9 | } 10 | 11 | interface NovitaResponse { 12 | data: NovitaModel[]; 13 | } 14 | 15 | export async function fetchNovitaData(apiKey: string | undefined): Promise { 16 | if (!apiKey) { 17 | console.warn("Novita API key not provided. Skipping Novita fetch."); 18 | return {}; 19 | } 20 | try { 21 | const response = await fetch(NOVITA_API_URL, { 22 | headers: { 23 | Authorization: `Bearer ${apiKey}`, 24 | }, 25 | }); 26 | if (!response.ok) { 27 | throw new Error(`Novita API request failed: ${response.status} ${response.statusText}`); 28 | } 29 | const data: NovitaResponse = await response.json(); 30 | const modelsData: MaxTokensCache["novita"] = {}; 31 | 32 | if (data?.data && Array.isArray(data.data)) { 33 | for (const model of data.data) { 34 | if (model.id && typeof model.context_size === "number") { 35 | modelsData[model.id] = model.context_size; 36 | } 37 | } 38 | } else { 39 | console.warn("Unexpected response structure from Novita API:", data); 40 | } 41 | return modelsData; 42 | } catch (error) { 43 | console.error("Error fetching Novita data:", error); 44 | return {}; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/server/providers/replicate.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const REPLICATE_API_URL = "https://api.replicate.com/v1/models"; 4 | 5 | export async function fetchReplicateData(apiKey: string | undefined): Promise { 6 | if (!apiKey) { 7 | console.warn("Replicate API key not provided. Skipping Replicate fetch."); 8 | return {}; 9 | } 10 | try { 11 | const response = await fetch(REPLICATE_API_URL, { 12 | headers: { 13 | Authorization: `Token ${apiKey}`, 14 | }, 15 | }); 16 | if (!response.ok) { 17 | throw new Error(`Replicate API request failed: ${response.status} ${response.statusText}`); 18 | } 19 | const data = await response.json(); 20 | const modelsData: MaxTokensCache["replicate"] = {}; 21 | 22 | if (data?.results && Array.isArray(data.results)) { 23 | for (const model of data.results) { 24 | const contextLength = model.context_length ?? model.config?.max_tokens ?? null; 25 | if (model.id && typeof contextLength === "number") { 26 | modelsData[model.id] = contextLength; 27 | } 28 | } 29 | } else { 30 | console.warn("Unexpected response structure from Replicate API:", data); 31 | } 32 | return modelsData; 33 | } catch (error) { 34 | console.error("Error fetching Replicate data:", error); 35 | return {}; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/server/providers/sambanova.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const SAMBANOVA_API_URL = "https://api.sambanova.ai/v1/models"; 4 | 5 | interface SambanovaModel { 6 | id: string; 7 | object: string; 8 | context_length: number; 9 | max_completion_tokens?: number; 10 | pricing?: { 11 | prompt: string; 12 | completion: string; 13 | }; 14 | } 15 | 16 | interface SambanovaResponse { 17 | data: SambanovaModel[]; 18 | object: string; 19 | } 20 | 21 | export async function fetchSambanovaData(apiKey: string | undefined): Promise { 22 | if (!apiKey) { 23 | console.warn("SambaNova API key not provided. Skipping SambaNova fetch."); 24 | return {}; 25 | } 26 | try { 27 | const response = await fetch(SAMBANOVA_API_URL, { 28 | headers: { 29 | Authorization: `Bearer ${apiKey}`, 30 | }, 31 | }); 32 | if (!response.ok) { 33 | throw new Error(`SambaNova API request failed: ${response.status} ${response.statusText}`); 34 | } 35 | const data: SambanovaResponse = await response.json(); 36 | const modelsData: MaxTokensCache["sambanova"] = {}; 37 | 38 | if (data?.data && Array.isArray(data.data)) { 39 | for (const model of data.data) { 40 | if (model.id && typeof model.context_length === "number") { 41 | modelsData[model.id] = model.context_length; 42 | } 43 | } 44 | } else { 45 | console.warn("Unexpected response structure from SambaNova API:", data); 46 | } 47 | return modelsData; 48 | } catch (error) { 49 | console.error("Error fetching SambaNova data:", error); 50 | return {}; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/server/providers/together.ts: -------------------------------------------------------------------------------- 1 | import type { MaxTokensCache } from "./index.js"; 2 | 3 | const TOGETHER_API_URL = "https://api.together.xyz/v1/models"; 4 | 5 | // Accept apiKey as an argument 6 | export async function fetchTogetherData(apiKey: string | undefined): Promise { 7 | if (!apiKey) { 8 | console.warn("Together AI API key not provided. Skipping Together AI fetch."); 9 | return {}; 10 | } 11 | try { 12 | const response = await fetch(TOGETHER_API_URL, { 13 | headers: { 14 | Authorization: `Bearer ${apiKey}`, // Use passed-in apiKey 15 | }, 16 | }); 17 | if (!response.ok) { 18 | throw new Error(`Together AI API request failed: ${response.status} ${response.statusText}`); 19 | } 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | const data: any[] = await response.json(); 22 | const modelsData: MaxTokensCache["together"] = {}; 23 | 24 | if (Array.isArray(data)) { 25 | for (const model of data) { 26 | const contextLength = model.context_length ?? model.config?.max_tokens ?? null; 27 | if (model.id && typeof contextLength === "number") { 28 | modelsData[model.id] = contextLength; 29 | } 30 | } 31 | } 32 | return modelsData; 33 | } catch (error) { 34 | console.error("Error fetching Together AI data:", error); 35 | return {}; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/spells/README.md: -------------------------------------------------------------------------------- 1 | # Spells 2 | 3 | Spells are special functions that use Runes under the hood, akin to Vue's composables or React hooks. They are only meant to be used inside other Spells, or within Svelte components. 4 | -------------------------------------------------------------------------------- /src/lib/spells/abort-manager.svelte.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy } from "svelte"; 2 | import { createInit } from "./create-init.svelte"; 3 | 4 | /** 5 | * Manages abort controllers, and aborts them when the component unmounts. 6 | */ 7 | export class AbortManager { 8 | private controllers: AbortController[] = []; 9 | 10 | constructor() { 11 | this.init(); 12 | } 13 | 14 | init = createInit(() => { 15 | try { 16 | onDestroy(() => this.abortAll()); 17 | } catch { 18 | // no-op 19 | } 20 | }); 21 | 22 | /** 23 | * Creates a new abort controller and adds it to the manager. 24 | */ 25 | public createController(): AbortController { 26 | const controller = new AbortController(); 27 | this.controllers.push(controller); 28 | return controller; 29 | } 30 | 31 | /** 32 | * Aborts all controllers and clears the manager. 33 | */ 34 | public abortAll(): void { 35 | this.controllers.forEach(controller => controller.abort()); 36 | this.controllers = []; 37 | } 38 | 39 | /** Clears the manager without aborting the controllers. */ 40 | public clear(): void { 41 | this.controllers = []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/spells/create-init.svelte.ts: -------------------------------------------------------------------------------- 1 | export function createInit(cb: () => void) { 2 | let called = $state(false); 3 | 4 | function init() { 5 | if (called) return; 6 | called = true; 7 | cb(); 8 | } 9 | 10 | return Object.defineProperties(init, { 11 | called: { 12 | get() { 13 | return called; 14 | }, 15 | enumerable: true, 16 | }, 17 | }) as typeof init & { readonly called: boolean }; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/spells/extract.svelte.ts: -------------------------------------------------------------------------------- 1 | import { isFunction } from "$lib/utils/is.js"; 2 | import type { MaybeGetter } from "$lib/types.js"; 3 | 4 | /** 5 | * Extracts the value from a getter or a value. 6 | * Optionally, a default value can be provided. 7 | */ 8 | export function extract( 9 | value: MaybeGetter, 10 | defaultValue?: D 11 | ): D extends undefined | null ? T : Exclude | D { 12 | if (isFunction(value)) { 13 | const getter = value; 14 | const gotten = getter(); 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | return (gotten ?? defaultValue ?? gotten) as any; 17 | } 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | return (value ?? defaultValue ?? value) as any; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/spells/is-dark.svelte.ts: -------------------------------------------------------------------------------- 1 | import { createSubscriber } from "svelte/reactivity"; 2 | 3 | const subscribe = createSubscriber(update => { 4 | const mutationObserver = new MutationObserver(entries => { 5 | for (const entry of entries) { 6 | if (entry.type === "attributes" && entry.attributeName === "class") { 7 | update(); 8 | } 9 | } 10 | }); 11 | mutationObserver.observe(document.body, { attributes: true }); 12 | 13 | return () => { 14 | mutationObserver.disconnect(); 15 | }; 16 | }); 17 | 18 | export function isDark() { 19 | subscribe(); 20 | return document.body.classList.contains("dark"); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/spells/synced.svelte.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeGetter } from "$lib/types.js"; 2 | import { isFunction } from "$lib/utils/is.js"; 3 | import { extract } from "./extract.svelte"; 4 | 5 | type SyncedArgs = 6 | | { 7 | value: MaybeGetter; 8 | onChange?: (value: T) => void; 9 | } 10 | | { 11 | value: MaybeGetter; 12 | onChange?: (value: T) => void; 13 | defaultValue: T; 14 | }; 15 | 16 | /** 17 | * Setting `current` calls the `onChange` callback with the new value. 18 | * 19 | * If the value arg is static, it will be used as the default value, 20 | * and subsequent sets will set an internal state that gets read as `current`. 21 | * 22 | * Otherwise, if it is a getter, it will be called every time `current` is read, 23 | * and no internal state is used. 24 | */ 25 | export class Synced { 26 | #internalValue = $state() as T; 27 | 28 | #valueArg: SyncedArgs["value"]; 29 | #onChange?: SyncedArgs["onChange"]; 30 | #defaultValue?: T; 31 | 32 | constructor({ value, onChange, ...args }: SyncedArgs) { 33 | this.#valueArg = value; 34 | this.#onChange = onChange; 35 | this.#defaultValue = "defaultValue" in args ? args?.defaultValue : undefined; 36 | this.#internalValue = extract(value, this.#defaultValue) as T; 37 | } 38 | 39 | get current() { 40 | return isFunction(this.#valueArg) 41 | ? (this.#valueArg() ?? this.#defaultValue ?? this.#internalValue) 42 | : this.#internalValue; 43 | } 44 | 45 | set current(value: T) { 46 | if (this.current === value) return; 47 | if (isFunction(this.#valueArg)) { 48 | this.#onChange?.(value); 49 | return; 50 | } 51 | 52 | this.#internalValue = value; 53 | this.#onChange?.(value); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/spells/textarea-autosize.svelte.ts: -------------------------------------------------------------------------------- 1 | import { useResizeObserver, watch } from "runed"; 2 | import { onDestroy, tick } from "svelte"; 3 | import type { Attachment } from "svelte/attachments"; 4 | import { on } from "svelte/events"; 5 | import { extract } from "./extract.svelte.js"; 6 | 7 | export interface TextareaAutosizeOptions { 8 | /** Function called when the textarea size changes. */ 9 | onResize?: () => void; 10 | /** 11 | * Specify the style property that will be used to manipulate height. Can be `height | minHeight`. 12 | * @default `height` 13 | **/ 14 | styleProp?: "height" | "minHeight"; 15 | /** 16 | * Maximum height of the textarea before enabling scrolling. 17 | * @default `undefined` (no maximum) 18 | */ 19 | maxHeight?: number; 20 | } 21 | 22 | export class TextareaAutosize { 23 | #options: TextareaAutosizeOptions; 24 | #resizeTimeout: number | null = null; 25 | #hiddenTextarea: HTMLTextAreaElement | null = null; 26 | 27 | element = $state(); 28 | input = $state(""); 29 | styleProp = $derived.by(() => extract(this.#options.styleProp, "height")); 30 | maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined)); 31 | textareaHeight = $state(0); 32 | textareaOldWidth = $state(0); 33 | 34 | constructor(options: TextareaAutosizeOptions = {}) { 35 | this.#options = options; 36 | 37 | // Create hidden textarea for measurements 38 | this.#createHiddenTextarea(); 39 | 40 | watch([() => this.input, () => this.element], () => { 41 | tick().then(() => this.triggerResize()); 42 | }); 43 | 44 | watch( 45 | () => this.textareaHeight, 46 | () => options?.onResize?.() 47 | ); 48 | 49 | useResizeObserver( 50 | () => this.element, 51 | ([entry]) => { 52 | if (!entry) return; 53 | const { contentRect } = entry; 54 | if (this.textareaOldWidth === contentRect.width) return; 55 | 56 | this.textareaOldWidth = contentRect.width; 57 | this.triggerResize(); 58 | } 59 | ); 60 | 61 | onDestroy(() => { 62 | // Clean up 63 | if (this.#hiddenTextarea) { 64 | this.#hiddenTextarea.remove(); 65 | this.#hiddenTextarea = null; 66 | } 67 | 68 | if (this.#resizeTimeout) { 69 | window.cancelAnimationFrame(this.#resizeTimeout); 70 | this.#resizeTimeout = null; 71 | } 72 | }); 73 | } 74 | 75 | #createHiddenTextarea() { 76 | // Create a hidden textarea that will be used for measurements 77 | // This avoids layout shifts caused by manipulating the actual textarea 78 | if (typeof window === "undefined") return; 79 | 80 | this.#hiddenTextarea = document.createElement("textarea"); 81 | const style = this.#hiddenTextarea.style; 82 | 83 | // Make it invisible but keep same text layout properties 84 | style.visibility = "hidden"; 85 | style.position = "absolute"; 86 | style.overflow = "hidden"; 87 | style.height = "0"; 88 | style.top = "0"; 89 | style.left = "-9999px"; 90 | 91 | document.body.appendChild(this.#hiddenTextarea); 92 | } 93 | 94 | #copyStyles() { 95 | if (!this.element || !this.#hiddenTextarea) return; 96 | 97 | const computed = window.getComputedStyle(this.element); 98 | 99 | // Copy all the styles that affect text layout 100 | const stylesToCopy = [ 101 | "box-sizing", 102 | "width", 103 | "padding-top", 104 | "padding-right", 105 | "padding-bottom", 106 | "padding-left", 107 | "border-top-width", 108 | "border-right-width", 109 | "border-bottom-width", 110 | "border-left-width", 111 | "font-family", 112 | "font-size", 113 | "font-weight", 114 | "font-style", 115 | "letter-spacing", 116 | "text-indent", 117 | "text-transform", 118 | "line-height", 119 | "word-spacing", 120 | "word-wrap", 121 | "word-break", 122 | "white-space", 123 | ]; 124 | 125 | stylesToCopy.forEach(style => { 126 | this.#hiddenTextarea!.style.setProperty(style, computed.getPropertyValue(style)); 127 | }); 128 | 129 | // Ensure the width matches exactly 130 | this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`; 131 | } 132 | 133 | triggerResize = () => { 134 | if (!this.element || !this.#hiddenTextarea) return; 135 | 136 | // Copy current styles and content to hidden textarea 137 | this.#copyStyles(); 138 | this.#hiddenTextarea.value = this.input || ""; 139 | 140 | // Measure the hidden textarea 141 | const scrollHeight = this.#hiddenTextarea.scrollHeight; 142 | 143 | // Apply the height, respecting maxHeight if set 144 | let newHeight = scrollHeight; 145 | if (this.maxHeight && newHeight > this.maxHeight) { 146 | newHeight = this.maxHeight; 147 | this.element.style.overflowY = "auto"; 148 | } else { 149 | this.element.style.overflowY = "hidden"; 150 | } 151 | 152 | // Only update if height actually changed 153 | if (this.textareaHeight !== newHeight) { 154 | this.textareaHeight = newHeight; 155 | this.element.style[this.styleProp] = `${newHeight}px`; 156 | } 157 | }; 158 | 159 | attachment: Attachment = node => { 160 | this.element = node; 161 | this.input = node.value; 162 | 163 | // Detect programmatic changes 164 | const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!; 165 | Object.defineProperty(node, "value", { 166 | get: desc.get, 167 | set: v => { 168 | const cleanup = $effect.root(() => { 169 | this.input = v; 170 | }); 171 | cleanup(); 172 | desc.set?.call(node, v); 173 | }, 174 | }); 175 | 176 | const removeListener = on(node, "input", _ => { 177 | this.input = node.value; 178 | }); 179 | 180 | return () => { 181 | removeListener(); 182 | this.element = undefined; 183 | }; 184 | }; 185 | } 186 | -------------------------------------------------------------------------------- /src/lib/state/checkpoints.svelte.ts: -------------------------------------------------------------------------------- 1 | import { idb } from "$lib/remult.js"; 2 | import { snapshot } from "$lib/utils/object.svelte"; 3 | import { dequal } from "dequal"; 4 | import { Entity, Fields, repo } from "remult"; 5 | import { conversations, type ConversationEntityMembers } from "./conversations.svelte"; 6 | import { ProjectEntity, projects } from "./projects.svelte"; 7 | 8 | @Entity("checkpoint") 9 | export class Checkpoint { 10 | @Fields.cuid() 11 | id!: string; 12 | 13 | @Fields.boolean() 14 | favorite: boolean = false; 15 | 16 | @Fields.createdAt() 17 | timestamp!: Date; 18 | 19 | @Fields.json() 20 | conversations: ConversationEntityMembers[] = []; 21 | 22 | @Fields.string() 23 | projectId!: string; 24 | } 25 | 26 | const checkpointsRepo = repo(Checkpoint, idb); 27 | 28 | class Checkpoints { 29 | #checkpoints: Record = $state({}); 30 | 31 | for(projectId: ProjectEntity["id"]) { 32 | // Async load from db 33 | checkpointsRepo 34 | .find({ 35 | where: { 36 | projectId, 37 | }, 38 | }) 39 | .then(c => { 40 | // Dequal to avoid infinite loops 41 | if (dequal(c, this.#checkpoints[projectId])) return; 42 | this.#checkpoints[projectId] = c; 43 | }); 44 | 45 | return ( 46 | this.#checkpoints[projectId]?.toSorted((a, b) => { 47 | const aTime = a.timestamp?.getTime() ?? new Date().getTime(); 48 | const bTime = b.timestamp?.getTime() ?? new Date().getTime(); 49 | return bTime - aTime; 50 | }) ?? [] 51 | ); 52 | } 53 | 54 | async commit(projectId: ProjectEntity["id"]) { 55 | const project = projects.all.find(p => p.id == projectId); 56 | if (!project) return; 57 | 58 | const newCheckpoint = await checkpointsRepo.save( 59 | snapshot({ 60 | conversations: conversations.for(project.id).map(c => c.data), 61 | timestamp: new Date(), 62 | projectId: project.id, 63 | }) 64 | ); 65 | 66 | // Hack because dates are formatted to string by save 67 | newCheckpoint.conversations.forEach((c, i) => { 68 | newCheckpoint.conversations[i] = { ...c, createdAt: new Date(c.createdAt) }; 69 | }); 70 | 71 | const prev: Checkpoint[] = this.#checkpoints[projectId] ?? []; 72 | this.#checkpoints[projectId] = [...prev, newCheckpoint]; 73 | } 74 | 75 | restore(checkpoint: Checkpoint) { 76 | const cloned = snapshot(checkpoint); 77 | const modified = { 78 | ...cloned, 79 | conversations: cloned.conversations.map(c => ({ 80 | ...c, 81 | projectId: cloned.projectId, 82 | })), 83 | }; 84 | 85 | const project = projects.all.find(p => p.id == modified.projectId); 86 | if (!project) return; 87 | 88 | projects.activeId = modified.projectId; 89 | 90 | // conversations.deleteAllFrom(cloned.projectId); 91 | const prev = conversations.for(modified.projectId); 92 | modified.conversations.forEach((c, i) => { 93 | const prevC = prev[i]; 94 | if (prevC) return prevC.update({ ...c }); 95 | conversations.create({ 96 | ...c, 97 | projectId: modified.projectId, 98 | }); 99 | }); 100 | 101 | if (modified.conversations.length < prev.length) { 102 | prev.forEach((p, i) => { 103 | if (i < modified.conversations.length) return; 104 | conversations.delete(p.data); 105 | }); 106 | } 107 | } 108 | 109 | async toggleFavorite({ id, projectId }: Checkpoint) { 110 | if (!id) return; 111 | 112 | const p = await checkpointsRepo.findFirst({ id }); 113 | if (!p) return; 114 | 115 | await checkpointsRepo.update(id, { favorite: !p.favorite }); 116 | const prev: Checkpoint[] = snapshot(this.#checkpoints[projectId] ?? []); 117 | 118 | this.#checkpoints[projectId] = prev.map(c => { 119 | if (c.id !== id) return c; 120 | return { ...c, favorite: !c.favorite }; 121 | }); 122 | } 123 | 124 | async delete({ id, projectId }: Checkpoint) { 125 | if (!id) return; 126 | 127 | await checkpointsRepo.delete(id); 128 | 129 | const prev: Checkpoint[] = this.#checkpoints[projectId] ?? []; 130 | this.#checkpoints[projectId] = prev.filter(c => c.id != id); 131 | } 132 | 133 | async clear(projectId: ProjectEntity["id"]) { 134 | await checkpointsRepo.deleteMany({ where: { projectId } }); 135 | this.#checkpoints[projectId] = []; 136 | } 137 | 138 | async migrate(from: ProjectEntity["id"], to: ProjectEntity["id"]) { 139 | await checkpointsRepo.updateMany({ where: { projectId: from }, set: { projectId: to } }); 140 | 141 | const fromArr = snapshot(this.#checkpoints[from] ?? []); 142 | this.#checkpoints[to] = [ 143 | ...fromArr.map(c => ({ 144 | ...c, 145 | projectId: to, 146 | conversations: c.conversations.map(cn => ({ ...cn, projectId: to })), 147 | })), 148 | ]; 149 | this.#checkpoints[from] = []; 150 | } 151 | } 152 | 153 | export const checkpoints = new Checkpoints(); 154 | -------------------------------------------------------------------------------- /src/lib/state/images.svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; 2 | import { images } from "./images.svelte"; 3 | import { fileToDataURL, compressBase64Image } from "$lib/utils/file.js"; 4 | import { JsonEntityIndexedDbStorage } from "$lib/remult.js"; 5 | 6 | // Mock dependencies 7 | vi.mock("$lib/utils/file.js", () => ({ 8 | fileToDataURL: vi.fn(), 9 | compressBase64Image: vi.fn(), 10 | })); 11 | 12 | vi.mock("$lib/remult.js", () => { 13 | const mockStoreInstance = { 14 | setItem: vi.fn(), 15 | getItem: vi.fn(), 16 | deleteItem: vi.fn(), 17 | init: vi.fn().mockResolvedValue(undefined), // Mock init if it's called internally 18 | }; 19 | return { 20 | JsonEntityIndexedDbStorage: vi.fn(() => mockStoreInstance), 21 | }; 22 | }); 23 | 24 | // Helper to get the mocked store instance 25 | const getMockedStore = () => new JsonEntityIndexedDbStorage(); 26 | 27 | describe("Images", () => { 28 | beforeEach(() => { 29 | vi.clearAllMocks(); 30 | // Mock crypto.randomUUID 31 | vi.spyOn(window.crypto, "randomUUID").mockReturnValue("123e4567-e89b-12d3-a456-426614174000"); 32 | }); 33 | 34 | describe("upload", () => { 35 | it("should process a file, store it, and return a key", async () => { 36 | const mockFile = new File(["dummy content"], "test.png", { type: "image/png" }); 37 | const mockDataUrl = "data:image/png;base64,dummy"; 38 | const mockCompressedDataUrl = "data:image/jpeg;base64,compresseddummy"; 39 | 40 | (fileToDataURL as Mock).mockResolvedValue(mockDataUrl); 41 | (compressBase64Image as Mock).mockResolvedValue(mockCompressedDataUrl); 42 | const store = getMockedStore(); 43 | 44 | const key = await images.upload(mockFile); 45 | 46 | expect(fileToDataURL).toHaveBeenCalledWith(mockFile); 47 | expect(compressBase64Image).toHaveBeenCalledWith({ 48 | base64: mockDataUrl, 49 | maxSizeKB: 400, 50 | }); 51 | expect(store.setItem).toHaveBeenCalledWith(`image-123e4567-e89b-12d3-a456-426614174000`, mockCompressedDataUrl); 52 | expect(key).toBe(`image-123e4567-e89b-12d3-a456-426614174000`); 53 | }); 54 | }); 55 | 56 | describe("get", () => { 57 | it("should retrieve an item from the store", async () => { 58 | const mockKey = "image-123"; 59 | const mockStoredData = "data:image/jpeg;base64,somedata"; 60 | const store = getMockedStore(); 61 | (store.getItem as Mock).mockResolvedValue(mockStoredData); 62 | 63 | const result = await images.get(mockKey); 64 | 65 | expect(store.getItem).toHaveBeenCalledWith(mockKey); 66 | expect(result).toBe(mockStoredData); 67 | }); 68 | 69 | it("should return undefined if item not found (or whatever getItem returns)", async () => { 70 | const mockKey = "image-not-found"; 71 | const store = getMockedStore(); 72 | (store.getItem as Mock).mockResolvedValue(undefined); // Simulate item not found 73 | 74 | const result = await images.get(mockKey); 75 | 76 | expect(store.getItem).toHaveBeenCalledWith(mockKey); 77 | expect(result).toBeUndefined(); 78 | }); 79 | }); 80 | 81 | describe("delete", () => { 82 | it("should delete an item from the store", async () => { 83 | const mockKey = "image-to-delete"; 84 | const store = getMockedStore(); 85 | 86 | await images.delete(mockKey); 87 | 88 | expect(store.deleteItem).toHaveBeenCalledWith(mockKey); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/lib/state/images.svelte.ts: -------------------------------------------------------------------------------- 1 | import { compressBase64Image, fileToDataURL } from "$lib/utils/file.js"; 2 | import { JsonEntityIndexedDbStorage } from "$lib/remult.js"; 3 | 4 | const store = new JsonEntityIndexedDbStorage(); 5 | 6 | class Images { 7 | async upload(file: File) { 8 | const dataUrl = await fileToDataURL(file); 9 | const compressed = await compressBase64Image({ base64: dataUrl, maxSizeKB: 400 }); 10 | 11 | const key = `image-${crypto.randomUUID()}`; 12 | store.setItem(key, compressed); 13 | 14 | return key; 15 | } 16 | 17 | async get(key: string): Promise { 18 | return await store.getItem(key); 19 | } 20 | 21 | async delete(key: string) { 22 | return await store.deleteItem(key); 23 | } 24 | } 25 | 26 | export const images = new Images(); 27 | -------------------------------------------------------------------------------- /src/lib/state/models.svelte.ts: -------------------------------------------------------------------------------- 1 | import { page } from "$app/state"; 2 | import { Provider, type CustomModel } from "$lib/types.js"; 3 | import { edit, randomPick } from "$lib/utils/array.js"; 4 | import { safeParse } from "$lib/utils/json.js"; 5 | import typia from "typia"; 6 | import type { PageData } from "../../routes/$types.js"; 7 | import { conversations } from "./conversations.svelte"; 8 | 9 | const LOCAL_STORAGE_KEY = "hf_inference_playground_custom_models"; 10 | 11 | const pageData = $derived(page.data as PageData); 12 | 13 | export const structuredForbiddenProviders: Provider[] = [ 14 | Provider.Hyperbolic, 15 | Provider.Nebius, 16 | Provider.Novita, 17 | Provider.Sambanova, 18 | ]; 19 | 20 | class Models { 21 | remote = $derived(pageData.models); 22 | trending = $derived(this.remote.toSorted((a, b) => b.trendingScore - a.trendingScore).slice(0, 5)); 23 | nonTrending = $derived(this.remote.filter(m => !this.trending.includes(m))); 24 | all = $derived([...this.remote, ...this.custom]); 25 | 26 | constructor() { 27 | const savedData = localStorage.getItem(LOCAL_STORAGE_KEY); 28 | if (!savedData) return; 29 | 30 | const parsed = safeParse(savedData); 31 | const res = typia.validate(parsed); 32 | if (res.success) { 33 | this.#custom = parsed; 34 | } else { 35 | localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify([])); 36 | } 37 | } 38 | 39 | #custom = $state.raw([]); 40 | 41 | get custom() { 42 | return this.#custom; 43 | } 44 | 45 | set custom(models: CustomModel[]) { 46 | this.#custom = models; 47 | 48 | try { 49 | localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(models)); 50 | } catch (e) { 51 | console.error("Failed to save session to localStorage:", e); 52 | } 53 | } 54 | 55 | addCustom(model: CustomModel) { 56 | if (this.#custom.find(m => m.id === model.id)) return null; 57 | this.custom = [...this.custom, model]; 58 | return model; 59 | } 60 | 61 | upsertCustom(model: CustomModel) { 62 | const index = this.#custom.findIndex(m => m._id === model._id); 63 | if (index === -1) { 64 | this.addCustom(model); 65 | } else { 66 | this.custom = edit(this.custom, index, model); 67 | } 68 | } 69 | 70 | removeCustom(uuid: CustomModel["_id"]) { 71 | this.custom = this.custom.filter(m => m._id !== uuid); 72 | conversations.active.forEach(c => { 73 | if (c.model._id !== uuid) return; 74 | c.update({ modelId: randomPick(models.trending)?.id }); 75 | }); 76 | } 77 | } 78 | 79 | export const models = new Models(); 80 | -------------------------------------------------------------------------------- /src/lib/state/projects.svelte.ts: -------------------------------------------------------------------------------- 1 | import { idb } from "$lib/remult.js"; 2 | import { dequal } from "dequal"; 3 | import { Entity, Fields, repo, type MembersOnly } from "remult"; 4 | import { PersistedState } from "runed"; 5 | import { checkpoints } from "./checkpoints.svelte"; 6 | import { conversations } from "./conversations.svelte"; 7 | 8 | @Entity("project") 9 | export class ProjectEntity { 10 | @Fields.cuid() 11 | id!: string; 12 | 13 | @Fields.string() 14 | name!: string; 15 | 16 | @Fields.string() 17 | systemMessage?: string; 18 | } 19 | 20 | export type ProjectEntityMembers = MembersOnly; 21 | 22 | const projectsRepo = repo(ProjectEntity, idb); 23 | 24 | const LOCAL_STORAGE_KEY = "hf_inf_pg_active_pid"; 25 | export const DEFAULT_PROJECT_ID = "default"; 26 | const defaultProj = projectsRepo.create({ id: DEFAULT_PROJECT_ID, name: "Default" }); 27 | 28 | class Projects { 29 | #projects: Record = $state({ default: defaultProj }); 30 | #activeId = new PersistedState(LOCAL_STORAGE_KEY, "default"); 31 | 32 | get activeId() { 33 | return this.#activeId.current; 34 | } 35 | 36 | set activeId(id: string) { 37 | this.#activeId.current = id; 38 | } 39 | 40 | constructor() { 41 | projectsRepo.find().then(res => { 42 | if (!res.some(p => p.id === this.activeId)) this.activeId === DEFAULT_PROJECT_ID; 43 | 44 | res.forEach(p => { 45 | if (dequal(this.#projects[p.id], p)) return; 46 | this.#projects[p.id] = p; 47 | }); 48 | }); 49 | } 50 | 51 | async create(args: Omit): Promise { 52 | const p = await projectsRepo.save({ ...args }); 53 | this.#projects[p.id] = p; 54 | return p.id; 55 | } 56 | 57 | saveProject = async (args: { name: string; moveCheckpoints?: boolean }) => { 58 | const defaultProject = this.all.find(p => p.id === DEFAULT_PROJECT_ID); 59 | if (!defaultProject) return; 60 | 61 | const id = await this.create({ name: args.name, systemMessage: defaultProject.systemMessage }); 62 | 63 | if (args.moveCheckpoints) { 64 | checkpoints.migrate(defaultProject.id, id); 65 | } 66 | 67 | // conversations.migrate(defaultProject.id, id).then(_ => (this.#activeId.current = id)); 68 | conversations.migrate(defaultProject.id, id).then(() => { 69 | this.activeId = id; 70 | }); 71 | 72 | return id; 73 | }; 74 | 75 | setCurrent = async (id: string) => { 76 | await checkpoints.migrate(id, this.activeId); 77 | conversations.migrate(this.activeId, id).then(() => { 78 | this.#activeId.current = id; 79 | }); 80 | this.activeId = id; 81 | }; 82 | 83 | get current() { 84 | return this.#projects[this.activeId]; 85 | } 86 | 87 | get all() { 88 | return Object.values(this.#projects); 89 | } 90 | 91 | async update(data: ProjectEntity) { 92 | if (!data.id) return; 93 | await projectsRepo.upsert({ where: { id: data.id }, set: data }); 94 | this.#projects[data.id] = { ...data }; 95 | } 96 | 97 | async delete(id: string) { 98 | if (!id) return; 99 | 100 | await projectsRepo.delete(id); 101 | await conversations.deleteAllFrom(id); 102 | delete this.#projects[id]; 103 | 104 | if (this.activeId === id) { 105 | this.activeId = DEFAULT_PROJECT_ID; 106 | } 107 | } 108 | } 109 | 110 | export const projects = new Projects(); 111 | -------------------------------------------------------------------------------- /src/lib/state/token.svelte.ts: -------------------------------------------------------------------------------- 1 | import { safeParse } from "$lib/utils/json.js"; 2 | import typia from "typia"; 3 | 4 | const key = "hf_token"; 5 | 6 | class Token { 7 | #value = $state(""); 8 | writeToLocalStorage = $state(true); 9 | showModal = $state(false); 10 | 11 | constructor() { 12 | const storedHfToken = localStorage.getItem(key); 13 | const parsed = safeParse(storedHfToken ?? ""); 14 | this.value = typia.is(parsed) ? parsed : ""; 15 | } 16 | 17 | get value() { 18 | return this.#value; 19 | } 20 | 21 | set value(token: string) { 22 | if (this.writeToLocalStorage) { 23 | localStorage.setItem(key, JSON.stringify(token)); 24 | } 25 | this.#value = token; 26 | this.showModal = !token.length; 27 | } 28 | 29 | reset = () => { 30 | this.value = ""; 31 | localStorage.removeItem(key); 32 | }; 33 | } 34 | 35 | export const token = new Token(); 36 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { GenerationConfig } from "$lib/components/inference-playground/generation-config-settings.js"; 2 | import type { ChatCompletionInputMessage } from "@huggingface/tasks"; 3 | import typia from "typia"; 4 | import type { ConversationEntityMembers } from "./state/conversations.svelte"; 5 | 6 | export type ConversationMessage = Pick & { 7 | content?: string; 8 | images?: string[]; 9 | }; 10 | 11 | export type Conversation = { 12 | model: Model | CustomModel; 13 | config: GenerationConfig; 14 | messages: ConversationMessage[]; 15 | systemMessage: ConversationMessage; 16 | streaming: boolean; 17 | provider?: string; 18 | } & Pick; 19 | 20 | export type ConversationWithCustomModel = Conversation & { 21 | model: CustomModel; 22 | }; 23 | 24 | export type ConversationWithHFModel = Conversation & { 25 | model: Model; 26 | }; 27 | 28 | export const isHFModel = typia.createIs(); 29 | export const isCustomModel = typia.createIs(); 30 | 31 | interface TokenizerConfig { 32 | chat_template?: string | Array<{ name: string; template: string }>; 33 | model_max_length?: number; 34 | } 35 | 36 | // export type ModelWithTokenizer = Model & { 37 | // tokenizerConfig: TokenizerConfig; 38 | // }; 39 | 40 | export type Model = { 41 | _id: string; 42 | id: string; 43 | inferenceProviderMapping: InferenceProviderMapping[]; 44 | trendingScore: number; 45 | config: Config; 46 | tags: string[]; 47 | pipeline_tag: PipelineTag; 48 | library_name?: LibraryName; 49 | }; 50 | 51 | export type CustomModel = { 52 | id: string; 53 | /** UUID */ 54 | _id: string; 55 | endpointUrl: string; 56 | accessToken?: string; 57 | /** @default "text-generation" */ 58 | pipeline_tag?: PipelineTag; 59 | supports_response_schema?: boolean; 60 | }; 61 | 62 | export type Config = { 63 | architectures: string[]; 64 | model_type: string; 65 | tokenizer_config: TokenizerConfig; 66 | auto_map?: AutoMap; 67 | quantization_config?: QuantizationConfig; 68 | }; 69 | 70 | export type AutoMap = { 71 | AutoConfig: string; 72 | AutoModel?: string; 73 | AutoModelForCausalLM: string; 74 | AutoModelForSequenceClassification?: string; 75 | AutoModelForTokenClassification?: string; 76 | AutoModelForQuestionAnswering?: string; 77 | }; 78 | 79 | export type QuantizationConfig = { 80 | quant_method: string; 81 | bits?: number; 82 | }; 83 | 84 | // export type TokenizerConfig = { 85 | // bos_token?: Token | BosTokenEnum | null; 86 | // chat_template: ChatTemplateElement[] | string; 87 | // eos_token: Token | EOSTokenEnum; 88 | // pad_token?: Token | null | string; 89 | // unk_token?: Token | UnkTokenEnum | null; 90 | // use_default_system_prompt?: boolean; 91 | // }; 92 | 93 | export type Token = { 94 | __type: Type; 95 | content: Content; 96 | lstrip: boolean; 97 | normalized: boolean; 98 | rstrip: boolean; 99 | single_word: boolean; 100 | }; 101 | 102 | export enum Type { 103 | AddedToken = "AddedToken", 104 | } 105 | 106 | export enum Content { 107 | BeginOfSentence = "<|begin▁of▁sentence|>", 108 | ContentS = "", 109 | EndOfSentence = "<|end▁of▁sentence|>", 110 | S = "", 111 | Unk = "", 112 | } 113 | 114 | export enum BosTokenEnum { 115 | BeginOfText = "<|begin_of_text|>", 116 | Bos = "", 117 | BosToken = "", 118 | Endoftext = "<|endoftext|>", 119 | IMStart = "<|im_start|>", 120 | S = "", 121 | Startoftext = "<|startoftext|>", 122 | } 123 | 124 | export type ChatTemplateElement = { 125 | name: string; 126 | template: string; 127 | }; 128 | 129 | export enum EOSTokenEnum { 130 | EOS = "", 131 | EndOfText = "<|end_of_text|>", 132 | EndOfTurnToken = "<|END_OF_TURN_TOKEN|>", 133 | Endoftext = "<|endoftext|>", 134 | EotID = "<|eot_id|>", 135 | IMEnd = "<|im_end|>", 136 | S = "", 137 | } 138 | 139 | export enum UnkTokenEnum { 140 | Endoftext = "<|endoftext|>", 141 | Unk = "", 142 | } 143 | 144 | export type InferenceProviderMapping = { 145 | provider: string; 146 | providerId: string; 147 | status: Status; 148 | task: Task; 149 | }; 150 | 151 | export enum Provider { 152 | Cerebras = "cerebras", 153 | FalAI = "fal-ai", 154 | FireworksAI = "fireworks-ai", 155 | HFInference = "hf-inference", 156 | Hyperbolic = "hyperbolic", 157 | Nebius = "nebius", 158 | Novita = "novita", 159 | Replicate = "replicate", 160 | Sambanova = "sambanova", 161 | Together = "together", 162 | Cohere = "cohere", 163 | } 164 | 165 | export enum Status { 166 | Live = "live", 167 | Staging = "staging", 168 | } 169 | 170 | export enum Task { 171 | Conversational = "conversational", 172 | } 173 | 174 | export enum LibraryName { 175 | Mlx = "mlx", 176 | Transformers = "transformers", 177 | Vllm = "vllm", 178 | } 179 | 180 | export enum PipelineTag { 181 | TextGeneration = "text-generation", 182 | ImageTextToText = "image-text-to-text", 183 | } 184 | 185 | export const pipelineTagLabel: Record = { 186 | [PipelineTag.TextGeneration]: "Text→Text", 187 | [PipelineTag.ImageTextToText]: "Image+Text→Text", 188 | }; 189 | 190 | export type MaybeGetter = T | (() => T); 191 | 192 | export type ValueOf = T[keyof T]; 193 | 194 | export interface GenerationStatistics { 195 | latency: number; 196 | tokens: number; 197 | } 198 | 199 | export type ModelsJson = { 200 | [modelId: string]: ModelJsonSpec; 201 | }; 202 | 203 | export interface ModelJsonSpec { 204 | max_tokens?: number; 205 | max_input_tokens?: number; 206 | max_output_tokens?: number; 207 | input_cost_per_token?: number; 208 | output_cost_per_token?: number; 209 | output_cost_per_reasoning_token?: number; 210 | litellm_provider: string; 211 | mode?: string; 212 | supports_function_calling?: boolean; 213 | supports_parallel_function_calling?: boolean; 214 | supports_vision?: boolean; 215 | supports_audio_input?: boolean; 216 | supports_audio_output?: boolean; 217 | supports_prompt_caching?: boolean; 218 | supports_response_schema?: boolean; 219 | supports_system_messages?: boolean; 220 | supports_reasoning?: boolean; 221 | supports_web_search?: boolean; 222 | search_context_cost_per_query?: SearchContextCostPerQuery; 223 | deprecation_date?: string; 224 | } 225 | 226 | export interface SearchContextCostPerQuery { 227 | search_context_size_low: number; 228 | search_context_size_medium: number; 229 | search_context_size_high: number; 230 | } 231 | -------------------------------------------------------------------------------- /src/lib/utils/array.ts: -------------------------------------------------------------------------------- 1 | export function last(arr: T[]): T | undefined { 2 | return arr[arr.length - 1]; 3 | } 4 | 5 | export function randomPick(arr: T[]): T | undefined { 6 | return arr[Math.floor(Math.random() * arr.length)]; 7 | } 8 | 9 | /** Return an array with an edited element at the given index. */ 10 | export function edit(arr: T[], index: number, newValue: T): T[] { 11 | return arr.map((value, i) => (i === index ? newValue : value)); 12 | } 13 | 14 | type IterateReturn = [ 15 | T, 16 | { 17 | isFirst: boolean; 18 | isLast: boolean; 19 | array: T[]; 20 | index: number; 21 | length: number; 22 | }, 23 | ]; 24 | 25 | /** 26 | * Returns an an iterator that iterates over the given array. 27 | * Each returned item contains helpful properties, such as 28 | * `isFirst`, `isLast`, `array`, `index`, and `length` 29 | * 30 | * @param array The array to iterate over. 31 | * @returns An iterator that iterates over the given array. 32 | */ 33 | export function* iterate(array: T[]): Generator> { 34 | for (let i = 0; i < array.length; i++) { 35 | yield [ 36 | array[i]!, 37 | { 38 | isFirst: i === 0, 39 | isLast: i === array.length - 1, 40 | array, 41 | index: i, 42 | length: array.length, 43 | }, 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/utils/compare.ts: -------------------------------------------------------------------------------- 1 | export function compareStr(a: string, b: string) { 2 | return a.toLowerCase().localeCompare(b.toLowerCase()); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils/copy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copies a string to the clipboard, with a fallback for older browsers. 3 | * 4 | * @param text The string to copy to the clipboard. 5 | * @returns A promise that resolves when the text has been successfully copied, 6 | * or rejects if the copy operation fails. 7 | */ 8 | export async function copyToClipboard(text: string): Promise { 9 | if (navigator.clipboard) { 10 | try { 11 | await navigator.clipboard.writeText(text); 12 | return; // Resolve immediately if successful 13 | } catch { 14 | // Fallback to the older method 15 | } 16 | } 17 | 18 | // Fallback for browsers that don't support the Clipboard API 19 | try { 20 | const textArea = document.createElement("textarea"); 21 | textArea.value = text; 22 | 23 | // Avoid scrolling to bottom of page in MS Edge. 24 | textArea.style.top = "0"; 25 | textArea.style.left = "0"; 26 | textArea.style.position = "fixed"; 27 | 28 | document.body.appendChild(textArea); 29 | textArea.focus(); 30 | textArea.select(); 31 | 32 | const successful = document.execCommand("copy"); 33 | document.body.removeChild(textArea); 34 | 35 | if (!successful) { 36 | throw new Error("Failed to copy text using fallback method."); 37 | } 38 | } catch (err) { 39 | return Promise.reject(err); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/utils/date.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a Date object into a human-readable string. 3 | * @param date The Date object to format. 4 | * @param locale The locale to use for formatting (e.g., 'en-US', 'fr-FR'). Defaults to the system's locale. 5 | * @param options Optional formatting options for toLocaleDateString. 6 | * @returns The formatted date string. 7 | */ 8 | export function formatDate(date: Date, locale?: string, options?: Intl.DateTimeFormatOptions): string { 9 | // Provide a default locale and options if not provided 10 | const effectiveLocale = locale || undefined; // Using undefined will use the system's locale 11 | const effectiveOptions: Intl.DateTimeFormatOptions = options || { 12 | year: "numeric", 13 | month: "long", 14 | day: "numeric", 15 | }; 16 | 17 | try { 18 | return date.toLocaleDateString(effectiveLocale, effectiveOptions); 19 | } catch (error) { 20 | console.error("Error formatting date:", error); 21 | // Fallback to a simple format if toLocaleDateString fails 22 | return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`; 23 | } 24 | } 25 | /** 26 | * Formats a Date object into a human-readable string including both date and time. 27 | * @param date The Date object to format. 28 | * @param locale The locale to use for formatting (e.g., 'en-US', 'fr-FR'). Defaults to the system's locale. 29 | * @param options Optional formatting options for toLocaleString. 30 | * @returns The formatted date and time string. 31 | */ 32 | export function formatDateTime(date: Date, locale?: string, options?: Intl.DateTimeFormatOptions): string { 33 | // Provide a default locale and options if not provided 34 | const effectiveLocale = locale || undefined; // Using undefined will use the system's locale 35 | const effectiveOptions: Intl.DateTimeFormatOptions = options || { 36 | year: "numeric", 37 | month: "long", 38 | day: "numeric", 39 | hour: "numeric", 40 | minute: "numeric", 41 | second: "numeric", 42 | // timeZoneName: "short", // Optionally include the time zone name 43 | }; 44 | 45 | try { 46 | return date.toLocaleString(effectiveLocale, effectiveOptions); 47 | } catch (error) { 48 | console.error("Error formatting date and time:", error); 49 | // Fallback to a simple format if toLocaleString fails 50 | return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")} ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/utils/encode.ts: -------------------------------------------------------------------------------- 1 | export function encodeObject(obj: unknown): string { 2 | /** 3 | * Encodes an object to a string using JSON serialization and Base64 encoding. 4 | * 5 | * Args: 6 | * obj: The object to encode. 7 | * 8 | * Returns: 9 | * A string representation of the object. 10 | */ 11 | const jsonString: string = JSON.stringify(obj); 12 | const encodedString: string = btoa(unescape(encodeURIComponent(jsonString))); // btoa expects only ASCII chars 13 | return encodedString; 14 | } 15 | 16 | export function decodeString(encodedString: string): unknown { 17 | /** 18 | * Decodes a string to an object using Base64 decoding and JSON deserialization. 19 | * 20 | * Args: 21 | * encodedString: The string to decode. 22 | * 23 | * Returns: 24 | * The decoded object. 25 | */ 26 | try { 27 | const jsonString: string = decodeURIComponent(escape(atob(encodedString))); 28 | const obj: unknown = JSON.parse(jsonString); 29 | return obj; 30 | } catch { 31 | return null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/utils/file.ts: -------------------------------------------------------------------------------- 1 | export function fileToDataURL(file: File): Promise { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | 5 | reader.onload = function (event) { 6 | resolve(event.target?.result as string); 7 | }; 8 | 9 | reader.onerror = function (error) { 10 | reject(error); 11 | }; 12 | 13 | reader.readAsDataURL(file); 14 | }); 15 | } 16 | 17 | interface CompressBase64Options { 18 | base64: string; 19 | maxSizeKB: number; 20 | outputFormat?: string; // 'image/jpeg' | 'image/webp' 21 | minQuality?: number; // default: 0.1 22 | maxQuality?: number; // default: 1.0 23 | maxIterations?: number; // default: 10 24 | } 25 | 26 | export async function compressBase64Image(options: CompressBase64Options): Promise { 27 | const { 28 | base64, 29 | maxSizeKB, 30 | outputFormat = "image/jpeg", 31 | minQuality = 0.1, 32 | maxQuality = 1.0, 33 | maxIterations = 10, 34 | } = options; 35 | 36 | const img = await new Promise((resolve, reject) => { 37 | const image = new Image(); 38 | image.crossOrigin = "Anonymous"; 39 | image.onload = () => resolve(image); 40 | image.onerror = reject; 41 | image.src = base64; 42 | }); 43 | 44 | const canvas = document.createElement("canvas"); 45 | canvas.width = img.width; 46 | canvas.height = img.height; 47 | const ctx = canvas.getContext("2d"); 48 | if (!ctx) throw new Error("Could not get canvas context"); 49 | ctx.drawImage(img, 0, 0); 50 | 51 | let minQ = minQuality; 52 | let maxQ = maxQuality; 53 | let bestBase64 = ""; 54 | 55 | for (let i = 0; i < maxIterations; i++) { 56 | const q = (minQ + maxQ) / 2; 57 | const b64 = canvas.toDataURL(outputFormat, q); 58 | const size = getBase64ImageSize(b64).kilobytes; 59 | 60 | if (size > maxSizeKB) { 61 | maxQ = q; 62 | } else { 63 | minQ = q; 64 | bestBase64 = b64; 65 | } 66 | } 67 | 68 | // If no quality produced a small enough image, return the lowest quality result 69 | if (!bestBase64) { 70 | bestBase64 = canvas.toDataURL(outputFormat, minQuality); 71 | } 72 | 73 | return bestBase64; 74 | } 75 | 76 | /** 77 | * Get the size of a Base64 image string in bytes and kilobytes. 78 | * @param base64 - The Base64 image string (with or without data URL prefix). 79 | * @returns { bytes: number, kilobytes: number, megabytes: number } 80 | */ 81 | export function getBase64ImageSize(base64: string): { bytes: number; kilobytes: number; megabytes: number } { 82 | // Remove data URL prefix if present 83 | const cleanedBase64 = base64.split(",")[1] || base64; 84 | 85 | // Calculate padding 86 | const padding = (cleanedBase64.match(/=+$/) || [""])[0].length; 87 | 88 | // Calculate size in bytes 89 | const bytes = (cleanedBase64.length * 3) / 4 - padding; 90 | 91 | // Convert to kilobytes 92 | const kilobytes = bytes / 1024; 93 | 94 | // Convert to megabytes (optional) 95 | const megabytes = kilobytes / 1024; 96 | 97 | return { 98 | bytes: Math.round(bytes), 99 | kilobytes: parseFloat(kilobytes.toFixed(2)), 100 | megabytes: parseFloat(megabytes.toFixed(2)), 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/utils/form.svelte.ts: -------------------------------------------------------------------------------- 1 | export type CreateFieldValidationArgs = { 2 | validate: (v: string) => string | void | undefined; 3 | }; 4 | 5 | export function createFieldValidation(args: CreateFieldValidationArgs) { 6 | let valid = $state(true); 7 | let msg = $state(); 8 | 9 | const onblur = (e: Event & { currentTarget: HTMLInputElement }) => { 10 | const v = e.currentTarget?.value; 11 | const m = args.validate(v); 12 | valid = !m; 13 | msg = m ?? undefined; 14 | }; 15 | 16 | const oninput = (e: Event & { currentTarget: HTMLInputElement }) => { 17 | if (valid) return; 18 | const v = e.currentTarget.value; 19 | const m = args.validate(v); 20 | msg = m ? m : undefined; 21 | }; 22 | 23 | return { 24 | get valid() { 25 | return valid; 26 | }, 27 | get msg() { 28 | return msg; 29 | }, 30 | reset() { 31 | valid = true; 32 | msg = undefined; 33 | }, 34 | attrs: { 35 | onblur, 36 | oninput, 37 | }, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/utils/is.ts: -------------------------------------------------------------------------------- 1 | import { SvelteSet } from "svelte/reactivity"; 2 | import typia from "typia"; 3 | 4 | export function isHtmlElement(element: unknown): element is HTMLElement { 5 | return element instanceof HTMLElement; 6 | } 7 | 8 | export function isFunction(value: unknown): value is (...args: unknown[]) => unknown { 9 | return typeof value === "function"; 10 | } 11 | 12 | export function isSvelteSet(value: unknown): value is SvelteSet { 13 | return value instanceof SvelteSet; 14 | } 15 | 16 | export function isIterable(value: unknown): value is Iterable { 17 | return value !== null && typeof value === "object" && Symbol.iterator in value; 18 | } 19 | 20 | export function isObject(value: unknown): value is Record { 21 | return value !== null && typeof value === "object"; 22 | } 23 | 24 | export function isHtmlInputElement(element: unknown): element is HTMLInputElement { 25 | return element instanceof HTMLInputElement; 26 | } 27 | 28 | export function isString(value: unknown): value is string { 29 | return typeof value === "string"; 30 | } 31 | 32 | export function isTouch(event: PointerEvent): boolean { 33 | return event.pointerType === "touch"; 34 | } 35 | 36 | export function isPromise(value: unknown): value is Promise { 37 | return value instanceof Promise; 38 | } 39 | 40 | export const isNumber = typia.createIs(); 41 | -------------------------------------------------------------------------------- /src/lib/utils/json.ts: -------------------------------------------------------------------------------- 1 | export function safeParse(str: string) { 2 | try { 3 | return JSON.parse(str); 4 | } catch { 5 | return null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/utils/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import { onDestroy, onMount } from "svelte"; 2 | 3 | export const safeOnMount = (fn: (...args: unknown[]) => unknown) => { 4 | try { 5 | onMount(fn); 6 | } catch { 7 | return fn; 8 | } 9 | }; 10 | 11 | export const safeOnDestroy = (fn: (...args: unknown[]) => unknown) => { 12 | try { 13 | onDestroy(fn); 14 | } catch { 15 | return fn; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/utils/noop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A no operation function (does nothing) 3 | */ 4 | export function noop() { 5 | // do nothing 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/utils/object.svelte.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from "$lib/types.js"; 2 | 3 | // typed Object.keys 4 | export function keys(o: T) { 5 | return Object.keys(o) as Array<`${keyof T & (string | number | boolean | null | undefined)}`>; 6 | } 7 | 8 | // typed Object.entries 9 | export function entries(o: T): [keyof T, T[keyof T]][] { 10 | return Object.entries(o) as [keyof T, T[keyof T]][]; 11 | } 12 | 13 | // typed Object.fromEntries 14 | export function fromEntries(entries: [keyof T, T[keyof T]][]): T { 15 | return Object.fromEntries(entries) as T; 16 | } 17 | 18 | export function omit, K extends keyof T>(obj: T, ...keys: K[]): Omit { 19 | const result = {} as Omit; 20 | for (const key of Object.keys(obj)) { 21 | if (!keys.includes(key as unknown as K)) { 22 | result[key as keyof Omit] = obj[key] as ValueOf>; 23 | } 24 | } 25 | return result; 26 | } 27 | 28 | export function pick, K extends keyof T>(obj: T, ...keys: K[]): Pick { 29 | const result = {} as Pick; 30 | for (const key of keys) { 31 | result[key] = obj[key] as ValueOf>; 32 | } 33 | return result; 34 | } 35 | 36 | // $state.snapshot but types are preserved 37 | export function snapshot(s: T): T { 38 | return $state.snapshot(s) as T; 39 | } 40 | 41 | /** 42 | * Try and get a value from an object, or return undefined. 43 | * The key does not need to match the type of the object, so the 44 | * returned type is an union of all values, and undefined 45 | */ 46 | export function tryGet>(obj: T, key: string): T[keyof T] | undefined { 47 | return obj[key as keyof T]; 48 | } 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | type DeepMergeable = { [key: string]: any }; 52 | 53 | function isPlainObject(value: unknown): value is Record { 54 | return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype; 55 | } 56 | 57 | export function deepMerge(target: T, source: U): T & U { 58 | const result: DeepMergeable = { ...target }; 59 | 60 | for (const key in source) { 61 | if (Object.prototype.hasOwnProperty.call(source, key)) { 62 | const sourceValue = source[key]; 63 | const targetValue = result[key]; 64 | 65 | // Handle arrays - merge them 66 | if (Array.isArray(sourceValue)) { 67 | result[key] = Array.isArray(targetValue) ? [...targetValue, ...sourceValue] : [...sourceValue]; 68 | continue; 69 | } 70 | 71 | // Handle plain objects (not null, not arrays, not class instances) 72 | if (isPlainObject(sourceValue)) { 73 | result[key] = 74 | Object.prototype.hasOwnProperty.call(result, key) && isPlainObject(result[key]) 75 | ? deepMerge(result[key], sourceValue) 76 | : deepMerge({}, sourceValue); 77 | continue; 78 | } 79 | 80 | // Handle primitives and everything else 81 | result[key] = sourceValue; 82 | } 83 | } 84 | 85 | return result as T & U; 86 | } 87 | 88 | export function renameKey( 89 | obj: T, 90 | oldKey: keyof T, 91 | newKey: string 92 | ): { [K in keyof T as K extends typeof oldKey ? typeof newKey : K]: T[K] } { 93 | const entries = Object.entries(obj); 94 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 95 | const result: any = {}; 96 | for (const [key, value] of entries) { 97 | if (key === oldKey) { 98 | result[newKey] = value; 99 | } else { 100 | result[key] = value; 101 | } 102 | } 103 | return result; 104 | } 105 | -------------------------------------------------------------------------------- /src/lib/utils/platform.ts: -------------------------------------------------------------------------------- 1 | export function isMac() { 2 | return navigator.platform.toUpperCase().indexOf("MAC") >= 0; 3 | } 4 | 5 | export const cmdOrCtrl = isMac() ? "⌘" : "Ctrl"; 6 | export const optOrAlt = isMac() ? "⌥" : "Alt"; 7 | -------------------------------------------------------------------------------- /src/lib/utils/poll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Polls a predicate function until it returns a truthy value or times out. 3 | * @param predicate - Function to evaluate. Should return a value or a Promise. 4 | * @param options - Polling options. 5 | * @returns The truthy value returned by predicate, or undefined if timed out. 6 | */ 7 | export async function poll( 8 | predicate: () => T | Promise, 9 | options: { interval?: number; maxAttempts?: number } = {} 10 | ): Promise { 11 | const { interval = 10, maxAttempts = 200 } = options; 12 | 13 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 14 | const result = await predicate(); 15 | if (result) return result; 16 | await new Promise(resolve => setTimeout(resolve, interval)); 17 | } 18 | return undefined; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/utils/queue.ts: -------------------------------------------------------------------------------- 1 | type AsyncQueueFunction = () => Promise; 2 | 3 | interface QueueItem { 4 | asyncFunction: AsyncQueueFunction; 5 | resolve: (value: T | PromiseLike) => void; 6 | reject: (reason?: unknown) => void; 7 | } 8 | 9 | export class AsyncQueue { 10 | queue: QueueItem[] = []; 11 | private isProcessing = false; 12 | 13 | public add(asyncFunction: AsyncQueueFunction): Promise { 14 | return new Promise((resolve, reject) => { 15 | this.queue.push({ asyncFunction, resolve, reject }); 16 | this.processQueue(); 17 | }); 18 | } 19 | 20 | private async processQueue(): Promise { 21 | if (this.isProcessing) { 22 | return; 23 | } 24 | 25 | this.isProcessing = true; 26 | 27 | while (this.queue.length > 0) { 28 | const queueItem = this.queue.shift()!; 29 | 30 | try { 31 | const { asyncFunction, resolve } = queueItem; 32 | const result = await asyncFunction(); 33 | resolve(result); 34 | } catch (error) { 35 | console.error("Error processing queue item:", error); 36 | const { reject } = queueItem; 37 | reject(error); 38 | } 39 | } 40 | 41 | this.isProcessing = false; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/utils/search.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic fuzzy search function that searches through arrays and returns matching items 3 | * 4 | * @param options Configuration object for the fuzzy search 5 | * @returns Array of items that match the search criteria 6 | */ 7 | export default function fuzzysearch(options: { 8 | needle: string; 9 | haystack: T[]; 10 | property: keyof T | ((item: T) => string); 11 | }): T[] { 12 | const { needle, haystack, property } = options; 13 | 14 | if (!Array.isArray(haystack)) { 15 | throw new Error("Haystack must be an array"); 16 | } 17 | 18 | if (!property) { 19 | throw new Error("Property selector is required"); 20 | } 21 | 22 | // Convert needle to lowercase for case-insensitive matching 23 | const lowerNeedle = needle.toLowerCase(); 24 | 25 | // Filter the haystack to find matching items 26 | return haystack.filter(item => { 27 | // Extract the string value from the item based on the property selector 28 | const value = typeof property === "function" ? property(item) : String(item[property]); 29 | 30 | // Convert to lowercase for case-insensitive matching 31 | const lowerValue = value.toLowerCase(); 32 | 33 | // Perform the fuzzy search 34 | return fuzzyMatchString(lowerNeedle, lowerValue); 35 | }); 36 | } 37 | 38 | /** 39 | * Internal helper function that performs the actual fuzzy string matching 40 | */ 41 | function fuzzyMatchString(needle: string, haystack: string): boolean { 42 | const hlen = haystack.length; 43 | const nlen = needle.length; 44 | 45 | if (nlen > hlen) { 46 | return false; 47 | } 48 | 49 | if (nlen === hlen) { 50 | return needle === haystack; 51 | } 52 | 53 | outer: for (let i = 0, j = 0; i < nlen; i++) { 54 | const nch = needle.charCodeAt(i); 55 | while (j < hlen) { 56 | if (haystack.charCodeAt(j++) === nch) { 57 | continue outer; 58 | } 59 | } 60 | return false; 61 | } 62 | 63 | return true; 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number) { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { type Writable } from "svelte/store"; 2 | 3 | export function partialSet>(store: Writable, partial: Partial) { 4 | store.update(s => ({ ...s, ...partial })); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function pluralize(word: string, count: number): string { 2 | if (count === 1) { 3 | return word; 4 | } 5 | return word + "s"; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/utils/template.ts: -------------------------------------------------------------------------------- 1 | export function onchange(cb: (value: string, e: Event) => void): { onchange: (e: Event) => void } { 2 | return { 3 | onchange: (e: Event) => { 4 | const el = e.target as HTMLInputElement; 5 | if (!el) return; 6 | cb(el.value, e); 7 | }, 8 | }; 9 | } 10 | 11 | export function oninput(cb: (value: string, e: Event) => void): { oninput: (e: Event) => void } { 12 | return { 13 | oninput: (e: Event) => { 14 | const el = e.target as HTMLInputElement; 15 | if (!el) return; 16 | cb(el.value, e); 17 | }, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/utils/url.ts: -------------------------------------------------------------------------------- 1 | export function isValidURL(url: string): boolean { 2 | try { 3 | new URL(url); 4 | return true; 5 | } catch { 6 | return false; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | {@render children?.()} 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const ssr = false; 2 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/routes/+page.ts: -------------------------------------------------------------------------------- 1 | import type { PageLoad } from "./$types.js"; 2 | import type { ApiModelsResponse } from "./api/models/+server.js"; 3 | 4 | export const load: PageLoad = async ({ fetch }) => { 5 | const res = await fetch("/api/models"); 6 | const json: ApiModelsResponse = await res.json(); 7 | return json; 8 | }; 9 | -------------------------------------------------------------------------------- /src/routes/api/[...remult]/+server.ts: -------------------------------------------------------------------------------- 1 | import { api } from "$lib/server/api.js"; 2 | 3 | export const { GET, POST, PUT, DELETE } = api; 4 | -------------------------------------------------------------------------------- /src/routes/api/models/+server.ts: -------------------------------------------------------------------------------- 1 | import type { Model } from "$lib/types.js"; 2 | import { json } from "@sveltejs/kit"; 3 | import type { RequestHandler } from "./$types.js"; 4 | 5 | enum CacheStatus { 6 | SUCCESS = "success", 7 | PARTIAL = "partial", 8 | ERROR = "error", 9 | } 10 | 11 | type Cache = { 12 | data: Model[] | undefined; 13 | timestamp: number; 14 | status: CacheStatus; 15 | // Track failed models to selectively refetch them 16 | failedTokenizers: string[]; // Using array instead of Set for serialization compatibility 17 | failedApiCalls: { 18 | textGeneration: boolean; 19 | imageTextToText: boolean; 20 | }; 21 | }; 22 | 23 | const cache: Cache = { 24 | data: undefined, 25 | timestamp: 0, 26 | status: CacheStatus.ERROR, 27 | failedTokenizers: [], 28 | failedApiCalls: { 29 | textGeneration: false, 30 | imageTextToText: false, 31 | }, 32 | }; 33 | 34 | // The time between cache refreshes 35 | const FULL_CACHE_REFRESH = 1000 * 60 * 60; // 1 hour 36 | const PARTIAL_CACHE_REFRESH = 1000 * 60 * 15; // 15 minutes (shorter for partial results) 37 | 38 | const headers: HeadersInit = { 39 | "Upgrade-Insecure-Requests": "1", 40 | "Sec-Fetch-Dest": "document", 41 | "Sec-Fetch-Mode": "navigate", 42 | "Sec-Fetch-Site": "none", 43 | "Sec-Fetch-User": "?1", 44 | "Priority": "u=0, i", 45 | "Pragma": "no-cache", 46 | "Cache-Control": "no-cache", 47 | }; 48 | 49 | const requestInit: RequestInit = { 50 | credentials: "include", 51 | headers, 52 | method: "GET", 53 | mode: "cors", 54 | }; 55 | 56 | interface ApiQueryParams { 57 | pipeline_tag?: "text-generation" | "image-text-to-text"; 58 | filter: string; 59 | inference_provider: string; 60 | limit: number; 61 | expand: string[]; 62 | } 63 | 64 | const queryParams: ApiQueryParams = { 65 | filter: "conversational", 66 | inference_provider: "all", 67 | limit: 100, 68 | expand: ["inferenceProviderMapping", "config", "library_name", "pipeline_tag", "tags", "mask_token", "trendingScore"], 69 | }; 70 | 71 | const baseUrl = "https://huggingface.co/api/models"; 72 | 73 | function buildApiUrl(params: ApiQueryParams): string { 74 | const url = new URL(baseUrl); 75 | 76 | // Add simple params 77 | Object.entries(params).forEach(([key, value]) => { 78 | if (!Array.isArray(value)) { 79 | url.searchParams.append(key, String(value)); 80 | } 81 | }); 82 | 83 | // Handle array params specially 84 | params.expand.forEach(item => { 85 | url.searchParams.append("expand[]", item); 86 | }); 87 | 88 | return url.toString(); 89 | } 90 | 91 | export type ApiModelsResponse = { 92 | models: Model[]; 93 | }; 94 | 95 | function createResponse(data: ApiModelsResponse): Response { 96 | return json(data); 97 | } 98 | 99 | export const GET: RequestHandler = async ({ fetch }) => { 100 | const timestamp = Date.now(); 101 | 102 | // Determine if cache is valid 103 | const elapsed = timestamp - cache.timestamp; 104 | const cacheRefreshTime = cache.status === CacheStatus.SUCCESS ? FULL_CACHE_REFRESH : PARTIAL_CACHE_REFRESH; 105 | 106 | // Use cache if it's still valid and has data 107 | if (elapsed < cacheRefreshTime && cache.data?.length) { 108 | console.log(`Using ${cache.status} cache (${Math.floor(elapsed / 1000 / 60)} min old)`); 109 | return createResponse({ models: cache.data }); 110 | } 111 | 112 | try { 113 | // Determine which API calls we need to make based on cache status 114 | const needTextGenFetch = elapsed >= FULL_CACHE_REFRESH || cache.failedApiCalls.textGeneration; 115 | const needImgTextFetch = elapsed >= FULL_CACHE_REFRESH || cache.failedApiCalls.imageTextToText; 116 | 117 | // Track the existing models we'll keep 118 | const existingModels = new Map(); 119 | if (cache.data) { 120 | cache.data.forEach(model => { 121 | existingModels.set(model.id, model); 122 | }); 123 | } 124 | 125 | // Initialize new tracking for failed requests 126 | const newFailedTokenizers: string[] = []; 127 | const newFailedApiCalls = { 128 | textGeneration: false, 129 | imageTextToText: false, 130 | }; 131 | 132 | // Fetch models as needed 133 | let textGenModels: Model[] = []; 134 | let imgText2TextModels: Model[] = []; 135 | 136 | // Make the needed API calls in parallel 137 | const apiPromises: Promise[] = []; 138 | if (needTextGenFetch) { 139 | const url = buildApiUrl({ ...queryParams, pipeline_tag: "text-generation" }); 140 | apiPromises.push( 141 | fetch(url, requestInit).then(async response => { 142 | if (!response.ok) { 143 | console.error(`Error fetching text-generation models`, response.status, response.statusText); 144 | newFailedApiCalls.textGeneration = true; 145 | } else { 146 | textGenModels = await response.json(); 147 | } 148 | }) 149 | ); 150 | } 151 | 152 | if (needImgTextFetch) { 153 | apiPromises.push( 154 | fetch(buildApiUrl({ ...queryParams, pipeline_tag: "image-text-to-text" }), requestInit).then(async response => { 155 | if (!response.ok) { 156 | console.error(`Error fetching image-text-to-text models`, response.status, response.statusText); 157 | newFailedApiCalls.imageTextToText = true; 158 | } else { 159 | imgText2TextModels = await response.json(); 160 | } 161 | }) 162 | ); 163 | } 164 | 165 | await Promise.all(apiPromises); 166 | 167 | // If both needed API calls failed and we have cached data, use it 168 | if ( 169 | needTextGenFetch && 170 | newFailedApiCalls.textGeneration && 171 | needImgTextFetch && 172 | newFailedApiCalls.imageTextToText && 173 | cache.data?.length 174 | ) { 175 | console.log("All API requests failed. Using existing cache as fallback."); 176 | cache.status = CacheStatus.ERROR; 177 | cache.timestamp = timestamp; // Update timestamp to avoid rapid retry loops 178 | cache.failedApiCalls = newFailedApiCalls; 179 | return createResponse({ models: cache.data }); 180 | } 181 | 182 | // For API calls we didn't need to make, use cached models 183 | if (!needTextGenFetch && cache.data) { 184 | textGenModels = cache.data.filter(model => model.pipeline_tag === "text-generation").map(model => model as Model); 185 | } 186 | 187 | if (!needImgTextFetch && cache.data) { 188 | imgText2TextModels = cache.data 189 | .filter(model => model.pipeline_tag === "image-text-to-text") 190 | .map(model => model as Model); 191 | } 192 | 193 | const models: Model[] = [...textGenModels, ...imgText2TextModels].filter( 194 | m => m.inferenceProviderMapping.length > 0 195 | ); 196 | models.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase())); 197 | 198 | // Determine cache status based on failures 199 | const hasApiFailures = newFailedApiCalls.textGeneration || newFailedApiCalls.imageTextToText; 200 | 201 | const cacheStatus = hasApiFailures ? CacheStatus.PARTIAL : CacheStatus.SUCCESS; 202 | 203 | cache.data = models; 204 | cache.timestamp = timestamp; 205 | cache.status = cacheStatus; 206 | cache.failedTokenizers = newFailedTokenizers; 207 | cache.failedApiCalls = newFailedApiCalls; 208 | 209 | console.log( 210 | `Cache updated: ${models.length} models, status: ${cacheStatus}, ` + 211 | `failed tokenizers: ${newFailedTokenizers.length}, ` + 212 | `API failures: text=${newFailedApiCalls.textGeneration}, img=${newFailedApiCalls.imageTextToText}` 213 | ); 214 | 215 | return createResponse({ models }); 216 | } catch (error) { 217 | console.error("Error fetching models:", error); 218 | 219 | // If we have cached data, use it as fallback 220 | if (cache.data?.length) { 221 | cache.status = CacheStatus.ERROR; 222 | // Mark all API calls as failed so we retry them next time 223 | cache.failedApiCalls = { 224 | textGeneration: true, 225 | imageTextToText: true, 226 | }; 227 | return createResponse({ models: cache.data }); 228 | } 229 | 230 | // No cache available, return empty array 231 | cache.status = CacheStatus.ERROR; 232 | cache.timestamp = timestamp; 233 | cache.failedApiCalls = { 234 | textGeneration: true, 235 | imageTextToText: true, 236 | }; 237 | return createResponse({ models: [] }); 238 | } 239 | }; 240 | -------------------------------------------------------------------------------- /src/routes/usage/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huggingface/inference-playground/33e2c0d38def8a71c4ab1227bbbf7e13db5c7ba5/src/routes/usage/+page.svelte -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huggingface/inference-playground/33e2c0d38def8a71c4ab1227bbbf7e13db5c7ba5/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-node"; 2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | }, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true, 13 | "plugins": [ 14 | { 15 | "transform": "typia/lib/transform" 16 | } 17 | ], 18 | "strictNullChecks": true, 19 | "moduleResolution": "bundler", 20 | "experimentalDecorators": true 21 | }, 22 | "exclude": ["vite.config.ts"] 23 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 24 | // 25 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 26 | // from the referenced tsconfig.json - TypeScript does not merge them in 27 | } 28 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { svelteTesting } from "@testing-library/svelte/vite"; 2 | import { sveltekit } from "@sveltejs/kit/vite"; 3 | import { defineConfig } from "vite"; 4 | import UnpluginTypia from "@ryoppippi/unplugin-typia/vite"; 5 | import Icons from "unplugin-icons/vite"; 6 | 7 | export const isDev = process.env.NODE_ENV === "development"; 8 | 9 | export default defineConfig({ 10 | plugins: [ 11 | UnpluginTypia({ log: "verbose", cache: false }), 12 | sveltekit(), 13 | Icons({ compiler: "svelte", autoInstall: true }), 14 | ], 15 | server: { allowedHosts: isDev ? true : undefined }, 16 | test: { 17 | workspace: [ 18 | { 19 | extends: "./vite.config.ts", 20 | plugins: [svelteTesting()], 21 | test: { 22 | name: "client", 23 | environment: "browser", 24 | browser: { 25 | enabled: true, 26 | provider: "playwright", 27 | instances: [ 28 | { 29 | browser: "chromium", 30 | }, 31 | { 32 | browser: "firefox", 33 | }, 34 | ], 35 | }, 36 | clearMocks: true, 37 | include: ["src/**/*.svelte.{test,spec}.{js,ts}"], 38 | exclude: ["src/lib/server/**"], 39 | setupFiles: ["./vitest-setup-client.ts"], 40 | }, 41 | }, 42 | { 43 | extends: "./vite.config.ts", 44 | test: { 45 | name: "server", 46 | environment: "node", 47 | include: ["src/**/*.{test,spec}.{js,ts}"], 48 | exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], 49 | }, 50 | }, 51 | ], 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /vitest-setup-client.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // import "@testing-library/jest-dom/vitest"; 5 | // import { vi } from "vitest"; 6 | // import fakeIndexedDB from "fake-indexeddb"; 7 | // 8 | // // required for svelte5 + jsdom as jsdom does not support matchMedia 9 | // Object.defineProperty(window, "matchMedia", { 10 | // writable: true, 11 | // enumerable: true, 12 | // value: vi.fn().mockImplementation(query => ({ 13 | // matches: false, 14 | // media: query, 15 | // onchange: null, 16 | // addEventListener: vi.fn(), 17 | // removeEventListener: vi.fn(), 18 | // dispatchEvent: vi.fn(), 19 | // })), 20 | // }); 21 | // 22 | // globalThis.indexedDB = fakeIndexedDB; 23 | --------------------------------------------------------------------------------