├── .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 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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 |
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 |
71 |
--------------------------------------------------------------------------------
/src/lib/components/icon-custom.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 | {#if icon === "regen"}
14 |
20 | {/if}
21 |
22 | {#if icon === "refresh"}
23 |
29 | {/if}
30 |
--------------------------------------------------------------------------------
/src/lib/components/inference-playground/checkpoints-menu.svelte:
--------------------------------------------------------------------------------
1 |
30 |
31 |
37 |
38 |
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 |
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 |
48 |
dispatch("close"))}
55 | >
56 |
120 |
121 |
122 |
--------------------------------------------------------------------------------
/src/lib/components/inference-playground/img-preview.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
59 |
--------------------------------------------------------------------------------
/src/lib/components/inference-playground/model-selector-modal.svelte:
--------------------------------------------------------------------------------
1 |
71 |
72 |
73 |
74 |
75 |
78 |
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 |
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 |
95 |
--------------------------------------------------------------------------------
/src/lib/components/quota-modal.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
33 |
34 |
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 |
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 |
--------------------------------------------------------------------------------