diff --git a/discojs/src/index.ts b/discojs/src/index.ts index fd87f7752..d9392ea20 100644 --- a/discojs/src/index.ts +++ b/discojs/src/index.ts @@ -38,6 +38,7 @@ export type { ModelCardInfo, BatchLogs, HellaSwagDataset, + GenerationConfig, } from "./models/index.js"; export { @@ -50,6 +51,7 @@ export { ONNXModel, HELLASWAG_URL, evaluate_hellaswag, + DefaultGenerationConfig, } from "./models/index.js"; export type { GPTConfig, HellaSwagExample } from "./models/index.js"; @@ -69,7 +71,7 @@ export { export type { TaskProvider } from "./task/index.js"; export type { DataType, Network, DataFormat } from "./types/index.js"; -export { dataTypeValues } from "./types/index.js"; +export { dataTypeValues, isDataType } from "./types/index.js"; export { extractColumn } from "./processing/index.js"; diff --git a/discojs/src/models/generation.ts b/discojs/src/models/generation.ts new file mode 100644 index 000000000..12ef36bc3 --- /dev/null +++ b/discojs/src/models/generation.ts @@ -0,0 +1,19 @@ +export interface GenerationConfig { + // take random token weighted by its probability + // If false, predict the token with the highest probability. + doSample: boolean; + // the generation temperature (higher means more randomness). + // Set to 0 for greedy decoding. + temperature: number; + // only consider the topk most likely tokens for sampling. + // used if doSample is true. + topk: number; + // optional random seed for sampling. + seed?: number; +} + +export const DefaultGenerationConfig: GenerationConfig = { + temperature: 1.0, + doSample: true, + topk: 50, +}; diff --git a/discojs/src/models/implementations/gpt/config.ts b/discojs/src/models/implementations/gpt/config.ts index fa22f1beb..247bfec15 100644 --- a/discojs/src/models/implementations/gpt/config.ts +++ b/discojs/src/models/implementations/gpt/config.ts @@ -46,7 +46,7 @@ export const DefaultGPTConfig: Required = { nLayer: 3, nHead: 3, nEmbd: 48, - seed: Math.random(), + seed: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER), }; export type ModelSize = { @@ -73,24 +73,3 @@ export function getModelSizes(modelType: GPTModelType): Required { return { nLayer: 3, nHead: 3, nEmbd: 48 }; } } - -export interface GenerationConfig { - // take random token weighted by its probability - // If false, predict the token with the highest probability. - doSample: boolean; - // the generation temperature (higher means more randomness). - // Set to 0 for greedy decoding. - temperature: number; - // only consider the topk most likely tokens for sampling. - // used if doSample is true. - topk: number; - // random seed for sampling. - seed: number; -} - -export const DefaultGenerationConfig: Required = { - temperature: 1.0, - doSample: false, - seed: Math.random(), - topk: 50, -}; diff --git a/discojs/src/models/implementations/gpt/gpt.spec.ts b/discojs/src/models/implementations/gpt/gpt.spec.ts index 467110e03..05f3fe957 100644 --- a/discojs/src/models/implementations/gpt/gpt.spec.ts +++ b/discojs/src/models/implementations/gpt/gpt.spec.ts @@ -38,7 +38,7 @@ describe("gpt-tfjs", () => { const inputTokens = tokenizer.tokenize(data); const outputToken = ( - await model.predict(List.of(inputTokens), { seed }) + await model.predict(List.of(inputTokens), { seed, doSample: false }) ).first(); if (outputToken === undefined) throw new Error("empty prediction"); const output = tokenizer.decode([outputToken]); diff --git a/discojs/src/models/implementations/gpt/gpt.ts b/discojs/src/models/implementations/gpt/gpt.ts index ed4cdb2ca..186279178 100644 --- a/discojs/src/models/implementations/gpt/gpt.ts +++ b/discojs/src/models/implementations/gpt/gpt.ts @@ -15,14 +15,10 @@ import { EpochLogs } from "#models/logs"; import { Model } from "#models/model"; import { GPTModel } from "#models/implementations/gpt/model"; import evaluate from "#models/implementations/gpt/evaluate"; -import { - DefaultGPTConfig, - DefaultGenerationConfig, -} from "#models/implementations/gpt/config"; -import type { - GPTConfig, - GenerationConfig, -} from "#models/implementations/gpt/config"; +import { DefaultGPTConfig } from "#models/implementations/gpt/config"; +import type { GPTConfig } from "#models/implementations/gpt/config"; +import { DefaultGenerationConfig } from "#models/generation"; +import type { GenerationConfig } from "#models/generation"; const debug = createDebug("discojs:models:gpt"); @@ -199,7 +195,9 @@ export class GPT extends Model<"text"> { logits .slice([logits.shape[0] - 1]) .squeeze([0]) - .div(config.temperature) + .div( + config.doSample && config.temperature > 0 ? config.temperature : 1, + ) .softmax(), ); logits.dispose(); @@ -263,7 +261,7 @@ export class GPT extends Model<"text"> { return this.model; } - [Symbol.dispose](): void { + dispose(): void { if (this.model.optimizer !== undefined) { this.model.optimizer.dispose(); } diff --git a/discojs/src/models/index.ts b/discojs/src/models/index.ts index 40263a189..a7a5cb0da 100644 --- a/discojs/src/models/index.ts +++ b/discojs/src/models/index.ts @@ -2,6 +2,8 @@ export { Model } from "./model.js"; export type { BatchLogs, ValidationMetrics } from "./logs.js"; export { EpochLogs } from "./logs.js"; export { Tokenizer } from "./tokenizer.js"; +export { DefaultGenerationConfig } from "./generation.js"; +export type { GenerationConfig } from "./generation.js"; export type { GPTConfig, diff --git a/discojs/src/models/model.ts b/discojs/src/models/model.ts index b3225f27b..91c6e4464 100644 --- a/discojs/src/models/model.ts +++ b/discojs/src/models/model.ts @@ -54,5 +54,9 @@ export abstract class Model implements Disposable { * } * Calling f() will call the model's dispose method when exiting the function. */ - abstract [Symbol.dispose](): void; + [Symbol.dispose](): void { + this.dispose(); + } + + abstract dispose(): void; } diff --git a/discojs/src/models/onnx.spec.ts b/discojs/src/models/onnx.spec.ts index b7eb0018d..eb6456f99 100644 --- a/discojs/src/models/onnx.spec.ts +++ b/discojs/src/models/onnx.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { List } from "immutable"; import { AutoTokenizer } from "@xenova/transformers"; import { ONNXModel } from "#models/onnx"; -import { DefaultGenerationConfig } from "#models/implementations/gpt/config"; +import { DefaultGenerationConfig } from "#models/generation"; describe("ONNXModel.predict", { timeout: 50_000 }, () => { it("should generate the next token ID from a prompt", async () => { diff --git a/discojs/src/models/onnx.ts b/discojs/src/models/onnx.ts index ae06770ed..9aad6d893 100644 --- a/discojs/src/models/onnx.ts +++ b/discojs/src/models/onnx.ts @@ -5,9 +5,9 @@ import { List } from "immutable"; import type { WeightsContainer } from "#weights/index"; import type { Batched } from "#dataset/index"; import type { DataFormat } from "#types/index"; -import type { GenerationConfig as TFJSGenerationConfig } from "#models/implementations/gpt/config"; import { Model } from "#models/model"; -import { DefaultGenerationConfig } from "#models/implementations/gpt/config"; +import { DefaultGenerationConfig } from "#models/generation"; +import type { GenerationConfig } from "#models/generation"; export class ONNXModel extends Model<"text"> { readonly datatype = "text" as const; @@ -30,7 +30,7 @@ export class ONNXModel extends Model<"text"> { override async predict( batch: Batched, - options?: Partial, + options?: Partial, ): Promise> { const config = Object.assign({}, DefaultGenerationConfig, options); @@ -43,7 +43,7 @@ export class ONNXModel extends Model<"text"> { async #predictSingle( tokens: DataFormat.ModelEncoded["text"][0], - config: TFJSGenerationConfig, + config: GenerationConfig, ): Promise { const contextLength = (this.model.config as { max_position_embeddings?: number }) @@ -125,7 +125,7 @@ export class ONNXModel extends Model<"text"> { throw new Error("Weights setting not supported in ONNX models"); } - [Symbol.dispose](): void { + dispose() { // Dispose of the model to free up memory void this.model.dispose(); } diff --git a/discojs/src/models/tfjs.ts b/discojs/src/models/tfjs.ts index d2421b415..23e521343 100644 --- a/discojs/src/models/tfjs.ts +++ b/discojs/src/models/tfjs.ts @@ -209,7 +209,7 @@ export class TFJS extends Model { return [this.datatype, await ret]; } - [Symbol.dispose](): void { + dispose(): void { this.model.dispose(); } diff --git a/discojs/src/types/datatype.ts b/discojs/src/types/datatype.ts index 8c7f6dbc7..4d50aefb3 100644 --- a/discojs/src/types/datatype.ts +++ b/discojs/src/types/datatype.ts @@ -1,2 +1,9 @@ export const dataTypeValues = ["image", "tabular", "text"] as const; + export type DataType = (typeof dataTypeValues)[number]; + +export function isDataType(x: unknown): x is DataType { + return ( + typeof x == "string" && (dataTypeValues as readonly string[]).includes(x) + ); +} diff --git a/discojs/src/types/index.ts b/discojs/src/types/index.ts index d7c33c215..5d63d7120 100644 --- a/discojs/src/types/index.ts +++ b/discojs/src/types/index.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line no-restricted-syntax -- namespace re-export acceptable here export * as DataFormat from "./data_format.js"; -export { dataTypeValues } from "./datatype.js"; +export { dataTypeValues, isDataType } from "./datatype.js"; export type { DataType } from "./datatype.js"; export type { Network } from "./network.js"; diff --git a/package.json b/package.json index 5d8c1e385..8e13c87ea 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "lint": "eslint", "format:check": "prettier -c .", - "format:fix": "prettier -w .", + "format:fix": "prettier -w --list-different .", "check_cycles": "dpdm --tsconfig discojs/tsconfig.lib.json --circular --exit-code circular:1 'discojs/src/**/*.ts'" }, "dependencies": { diff --git a/server/package.json b/server/package.json index 0d6c60b70..51bf301aa 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,7 @@ }, "scripts": { "watch": "nodemon --ext ts --ignore dist --watch ../discojs-node/dist --watch . --exec pnpm run", - "start": "node dist/main.js", + "start": "pnpm run build && node dist/main.js", "build": "tsc --build", "test": "cd .. && vitest --run --project=server" }, diff --git a/webapp/.env.development b/webapp/.env.development index b1b0b2d25..e5662bf28 100644 --- a/webapp/.env.development +++ b/webapp/.env.development @@ -1 +1,4 @@ -VITE_SERVER_URL=http://localhost:8080 +# When set to localhost, the server host is derived from whichever +# host serves the webapp (localhost, or your LAN IP when running `vite --host`). +VITE_SERVER_URL=localhost +VITE_SERVER_PORT=8080 diff --git a/webapp/cypress/e2e/chatUI.cy.ts b/webapp/cypress/e2e/chatUI.cy.ts new file mode 100644 index 000000000..de1c2338f --- /dev/null +++ b/webapp/cypress/e2e/chatUI.cy.ts @@ -0,0 +1,235 @@ +import { defaultTasks } from "@epfml/discojs"; + +import { setupServerWith } from "../support/e2e"; + +/** + * Download the wikitext GPT model and open the chat page with it. + * + * Going through the model library rather than visiting /chat directly matters: + * without a `modelID`, the page falls back to downloading GPT2 from HuggingFace. + */ +function openChatWithDownloadedModel(): void { + setupServerWith(defaultTasks.wikitext); + + cy.visit("/evaluate"); + cy.contains("button", "download").click(); + cy.contains("button", "chat").click(); + + cy.url().should("contain", "/chat?modelID="); + cy.contains("Model loaded!", { timeout: 30_000 }); +} + +type Options = Partial; + +const PLACEHOLDER = "Generation will appear here..."; + +const generateButton = (options?: Options) => + cy.get('button[title="Generate"]', options); +const stopButton = () => cy.get('button[title="Stop"]'); +const clearButton = () => cy.get('button[title="Clear Generation"]'); +const promptInput = () => cy.get('input[type="text"]'); +const generation = (options?: Options) => + cy.get('[aria-live="polite"]', options); +/** The spans highlighting what the model generated. */ +const highlights = () => generation().find('span[class*="bg-disco-cyan"]'); +const sampling = () => cy.get('input[type="checkbox"]'); + +/** The range input of a parameter, found from its label. */ +const slider = (label: string) => + cy.contains("label", label).parent().find('input[type="range"]'); + +/** The value displayed next to a parameter's slider. */ +const sliderValue = (label: string) => + cy.contains("label", label).parent().find("span"); + +function setSlider(label: string, value: number): void { + slider(label).invoke("val", value).trigger("input"); +} + +describe("chat UI", () => { + // the parameters sidebar is collapsed below Tailwind's `lg` breakpoint + beforeEach(() => cy.viewport(1280, 800)); + + it("opens the chat page from a text model of the library", () => { + setupServerWith(defaultTasks.wikitext); + + cy.visit("/evaluate"); + cy.contains("button", "download").click(); + + // text models are chatted with, not tested nor predicted + cy.contains("Data type").siblings().should("have.text", "Text"); + cy.contains("button", "chat").click(); + + cy.url().should("contain", "/chat?modelID="); + cy.contains("LLM Playground"); + // the downloaded model is used, rather than the remote ONNX one + cy.get("select").should("have.value", "tfjs-gpt2"); + }); + + it("displays the default generation parameters", () => { + openChatWithDownloadedModel(); + + generation().should("have.text", PLACEHOLDER); + promptInput().should("have.value", "").and("be.enabled"); + + sampling().should("be.checked"); + sliderValue("Temperature").should("have.text", "1"); + sliderValue("Top-k").should("have.text", "50"); + sliderValue("Max Tokens").should("have.text", "50"); + }); + + it("only enables generation once a prompt is entered", () => { + openChatWithDownloadedModel(); + + generateButton().should("be.disabled"); + + promptInput().type(" "); + generateButton().should("be.disabled"); + + promptInput().type("DISCO is"); + generateButton().should("be.enabled"); + }); + + it("ties sampling to the temperature", () => { + openChatWithDownloadedModel(); + + // without sampling, the temperature is zero and the samplers are locked + sampling().uncheck(); + sliderValue("Temperature").should("have.text", "0"); + slider("Temperature").should("be.disabled"); + slider("Top-k").should("be.disabled"); + + sampling().check(); + sliderValue("Temperature").should("have.text", "1"); + slider("Temperature").should("be.enabled"); + slider("Top-k").should("be.enabled"); + + // and zeroing the temperature turns sampling back off + setSlider("Temperature", 0); + sampling().should("not.be.checked"); + }); + + it("resets parameters to their defaults", () => { + openChatWithDownloadedModel(); + + setSlider("Temperature", 1.5); + setSlider("Top-k", 10); + setSlider("Max Tokens", 200); + sliderValue("Temperature").should("have.text", "1.5"); + + cy.contains("button", "Reset").click(); + + sampling().should("be.checked"); + sliderValue("Temperature").should("have.text", "1"); + sliderValue("Top-k").should("have.text", "50"); + sliderValue("Max Tokens").should("have.text", "50"); + }); + + it("generates, highlights and clears text", () => { + openChatWithDownloadedModel(); + + setSlider("Max Tokens", 2); + promptInput().type("DISCO is{enter}"); + + // the prompt moves to the generation area and the input is emptied + generation().should("contain.text", "DISCO is"); + promptInput().should("have.value", ""); + + // generation is over once the button is idle again + generateButton({ timeout: 60_000 }).should("be.disabled"); + // only the generated tokens are highlighted + highlights() + .should("have.length", 1) + .invoke("text") + .should("not.be.empty") + .and("not.contain", "DISCO is"); + + clearButton().click(); + generation().should("have.text", PLACEHOLDER); + highlights().should("not.exist"); + }); + + it("stops an ongoing generation", () => { + openChatWithDownloadedModel(); + + setSlider("Max Tokens", 200); + promptInput().type("DISCO is"); + generateButton().click(); + + // whatever acts on the generation is disabled while it runs + clearButton().should("be.disabled"); + cy.contains("button", "Regenerate").should("be.disabled"); + promptInput().should("be.disabled"); + + stopButton().click(); + + generateButton({ timeout: 60_000 }).should("exist"); + clearButton().should("be.enabled"); + promptInput().should("be.enabled"); + }); + + it("regenerates from the last prompt", () => { + openChatWithDownloadedModel(); + + setSlider("Max Tokens", 2); + promptInput().type("DISCO is{enter}"); + generateButton({ timeout: 60_000 }).should("be.disabled"); + + cy.contains("button", "Regenerate").click(); + + // the prompt is kept and generated from again, rather than a second + // generation being appended to the first one + generation({ timeout: 60_000 }) + .invoke("text") + .should((text) => { + expect(text).to.match(/^DISCO is/); + expect(text.match(/DISCO is/g)).to.have.lengthOf(1); + expect(text.length).to.be.greaterThan("DISCO is".length); + }); + highlights().should("have.length", 1); + }); + + it("generates with the ONNX model", () => { + // transformers.js looks for a self-hosted copy under `/models/` before + // falling back to the HuggingFace hub, and only falls back on a 404 -- + // which the dev server never returns, answering its SPA fallback instead. + cy.intercept("/models/**", { statusCode: 404 }); + + // without a `modelID`, the page loads the pretrained ONNX GPT2: + // ~130 MB downloaded from HuggingFace, then cached by the browser + cy.visit("/chat"); + cy.get("select").should("have.value", "onnx-gpt2"); + cy.contains("Model loaded!", { timeout: 300_000 }); + + setSlider("Max Tokens", 2); + promptInput().type("DISCO is{enter}"); + + generation().should("contain.text", "DISCO is"); + generateButton({ timeout: 120_000 }).should("be.disabled"); + highlights() + .should("have.length", 1) + .invoke("text") + .should("not.be.empty") + .and("not.contain", "DISCO is"); + }); + + it("warns when the model can't be loaded", () => { + // e.g. a bookmarked link to a model that has since been removed + cy.visit("/chat?modelID=404"); + + cy.contains("An error occurred"); + }); + + it("collapses the parameters on small screens", () => { + cy.viewport("iphone-x"); + openChatWithDownloadedModel(); + + slider("Max Tokens").should("not.be.visible"); + + cy.contains("button", "Model Parameters").click(); + slider("Max Tokens").should("be.visible"); + + cy.contains("button", "Model Parameters").click(); + slider("Max Tokens").should("not.be.visible"); + }); +}); diff --git a/webapp/cypress/e2e/store/models.cy.ts b/webapp/cypress/e2e/store/models.cy.ts index 3c7e27e4c..79037c0a0 100644 --- a/webapp/cypress/e2e/store/models.cy.ts +++ b/webapp/cypress/e2e/store/models.cy.ts @@ -1,18 +1,6 @@ import { defaultTasks } from "@epfml/discojs"; import { setupServerWith } from "../../support/e2e"; -beforeEach(() => - cy.wrap(async () => { - const root = await navigator.storage.getDirectory(); - try { - await root.removeEntry("models", { recursive: true }); - } catch (e) { - if (e instanceof DOMException && e.name === "NotFoundError") return; - throw e; - } - }), -); - it( "stores models", { retries: 5 }, // can exhaust memory @@ -36,7 +24,7 @@ it( cy.visit("/evaluate"); cy.contains("button", "download").click(); - cy.contains("button", "test") + cy.contains("button", "chat") .should("exist") .then( () => @@ -45,6 +33,6 @@ it( ); cy.reload(); - cy.contains("button", "test").should("exist"); + cy.contains("button", "chat").should("exist"); }, ); diff --git a/webapp/cypress/e2e/testing.cy.ts b/webapp/cypress/e2e/testing.cy.ts index b001c0cc4..2ddd81552 100644 --- a/webapp/cypress/e2e/testing.cy.ts +++ b/webapp/cypress/e2e/testing.cy.ts @@ -44,23 +44,3 @@ it("can test lus_covid", () => { cy.contains("button", "download as csv", { timeout: 20_000 }); }); - -it("can start and stop testing of wikitext", () => { - setupServerWith(defaultTasks.wikitext); - - cy.visit("/evaluate"); - cy.contains("button", "download").click(); - cy.contains("button", "test").click(); - - cy.get('[data-testid="select-text-button"]') - .first() - .selectFile("../datasets/wikitext/wiki.test.tokens"); - cy.contains("button", "next").click(); - - cy.contains("Validate your model") - .parents() - .eq(1) - .contains("button", "test") - .click(); - cy.contains("button", "stop testing").click(); -}); diff --git a/webapp/cypress/support/e2e.ts b/webapp/cypress/support/e2e.ts index 0a55ba067..6a7d56ad0 100644 --- a/webapp/cypress/support/e2e.ts +++ b/webapp/cypress/support/e2e.ts @@ -91,3 +91,19 @@ export function basicTask( before(() => { localStorage.debug = "discojs*,webapp*"; }); + +// Models are persisted in OPFS, which Cypress doesn't clear between tests nor +// between specs. A model left over from another spec is shown by the model +// library, which throws when its task isn't in the (intercepted) task list. +beforeEach(() => + // cy.then rather than cy.wrap: the latter yields the function without calling it + cy.then(async () => { + const root = await navigator.storage.getDirectory(); + try { + await root.removeEntry("models", { recursive: true }); + } catch (e) { + if (e instanceof DOMException && e.name === "NotFoundError") return; + throw e; + } + }), +); diff --git a/webapp/env.d.ts b/webapp/env.d.ts index 11f02fe2a..6051ff51d 100644 --- a/webapp/env.d.ts +++ b/webapp/env.d.ts @@ -1 +1,11 @@ /// + +interface ImportMetaEnv { + /** Full URL of the Disco server. When set to "localhost", the URL is derived from window.location. */ + readonly VITE_SERVER_URL: string; + readonly VITE_SERVER_PORT?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/webapp/index.html b/webapp/index.html index ac36c1a97..41f412178 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -6,7 +6,7 @@ Disco diff --git a/webapp/package.json b/webapp/package.json index 00017ef77..8d83bac59 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -7,7 +7,7 @@ "build": "vue-tsc --build && vite build", "test": "pnpm run test:unit && pnpm run test:e2e", "test:unit": "vitest --run", - "test:e2e": "VITE_SERVER_URL=http://server start-server-and-test \"pnpm start\" http://localhost:1351 'pnpm exec cypress run --e2e'", + "test:e2e": "VITE_SERVER_URL=http://server start-server-and-test \"vite\" http://localhost:1351 'pnpm exec cypress run --e2e'", "test:e2e-interactive": "VITE_SERVER_URL=http://server start-server-and-test \"pnpm start\" http://localhost:1351 'pnpm exec cypress open --e2e'" }, "dependencies": { diff --git a/webapp/src/assets/svg/CleanIcon.vue b/webapp/src/assets/svg/CleanIcon.vue new file mode 100644 index 000000000..864c91f87 --- /dev/null +++ b/webapp/src/assets/svg/CleanIcon.vue @@ -0,0 +1,24 @@ + + diff --git a/webapp/src/assets/svg/MessageArrow.vue b/webapp/src/assets/svg/MessageArrow.vue new file mode 100644 index 000000000..9db1de518 --- /dev/null +++ b/webapp/src/assets/svg/MessageArrow.vue @@ -0,0 +1,23 @@ + + diff --git a/webapp/src/assets/svg/StopIcon.vue b/webapp/src/assets/svg/StopIcon.vue new file mode 100644 index 000000000..72148ac0c --- /dev/null +++ b/webapp/src/assets/svg/StopIcon.vue @@ -0,0 +1,23 @@ + + diff --git a/webapp/src/components/testing/ChatUI.vue b/webapp/src/components/testing/ChatUI.vue new file mode 100644 index 000000000..09c1d0fa3 --- /dev/null +++ b/webapp/src/components/testing/ChatUI.vue @@ -0,0 +1,573 @@ + + + diff --git a/webapp/src/components/testing/ModelLibrary.vue b/webapp/src/components/testing/ModelLibrary.vue index e58fe9ec4..47adb7451 100644 --- a/webapp/src/components/testing/ModelLibrary.vue +++ b/webapp/src/components/testing/ModelLibrary.vue @@ -23,10 +23,12 @@ > @@ -50,6 +52,10 @@ Storage size {{ infos.storageSize }} + + Data type + {{ capitalize(infos.dataType) }} + @@ -163,6 +169,7 @@ import type { ModelID } from "@/store"; import { useModelsStore } from "@/store"; import { useTasksStore } from "@/store"; import { useValidationStore } from "@/store"; +import { useRouter } from "vue-router"; import ButtonsCard from "@/components/containers/ButtonsCard.vue"; import IconCard from "@/components/containers/IconCard.vue"; @@ -178,6 +185,7 @@ const validationStore = useValidationStore(); const models = useModelsStore(); const { tasks } = storeToRefs(useTasksStore()); const toaster = useToaster(); +const router = useRouter(); type Selection = { mode: "predict" | "test"; @@ -187,6 +195,10 @@ type Selection = { }; const selection = ref>(); +function capitalize(val: string) { + return String(val).charAt(0).toUpperCase() + String(val).slice(1); +} + const federatedTasks = computed< "loading" | "failed" | List> >(() => { @@ -207,9 +219,10 @@ const sortedModelsInfos = computed(() => { return models.infos .sortBy((infos) => infos.dateSaved) - .map(({ taskID, dateSaved, storageSize }) => ({ + .map(({ taskID, dateSaved, dataType, storageSize }) => ({ taskID, dateSaved: shortDate.format(dateSaved), + dataType, storageSize: formatByteSize(storageSize), })) .reverse(); @@ -307,4 +320,9 @@ function taskTitle(taskID: string): string | undefined { return titled.displayInformation.title; } + +async function goToChat(modelID: ModelID) { + validationStore.step = 0; + await router.push({ path: "/chat", query: { modelID } }); +} diff --git a/webapp/src/config.ts b/webapp/src/config.ts index 437f58318..a59c0e36b 100644 --- a/webapp/src/config.ts +++ b/webapp/src/config.ts @@ -3,5 +3,21 @@ export interface Config { } export const CONFIG: Config = { - serverUrl: new URL("", import.meta.env.VITE_SERVER_URL), + serverUrl: resolveServerUrl(), }; + +/** + * The server URL is taken from VITE_SERVER_URL when set. When it isn't (the + * development default), it is derived from the host serving the webapp so that + * `vite --host` also works from another device on the LAN: the phone loads the + * webapp at http://:1351 and reaches the server at http://:8080 + * without anyone hardcoding . + */ +function resolveServerUrl(): URL { + const configured = import.meta.env.VITE_SERVER_URL; + if (configured && configured != "localhost") return new URL("", configured); + + const port = import.meta.env.VITE_SERVER_PORT || "8080"; + const { protocol, hostname } = window.location; + return new URL(`${protocol}//${hostname}:${port}`); +} diff --git a/webapp/src/router/router.ts b/webapp/src/router/router.ts index d1e8b52b9..bad9866b1 100644 --- a/webapp/src/router/router.ts +++ b/webapp/src/router/router.ts @@ -11,6 +11,7 @@ import NotFound from "@/components/pages/NotFound.vue"; import Training from "@/components/training/TrainingSteps.vue"; import ModelLibrary from "@/components/testing/ModelLibrary.vue"; import AboutUs from "@/components/pages/AboutUs.vue"; +import ChatUI from "@/components/testing/ChatUI.vue"; const debug = createDebug("webapp:router"); @@ -66,6 +67,13 @@ const router = createRouter({ ProgressBar: false, }, }, + { + path: "/chat", + name: "chat", + components: { + default: ChatUI, + }, + }, { path: "/:pathMatch(.*)*", name: "not-found", diff --git a/webapp/src/store/models/index.ts b/webapp/src/store/models/index.ts index 8ac462707..e77ee900b 100644 --- a/webapp/src/store/models/index.ts +++ b/webapp/src/store/models/index.ts @@ -18,9 +18,10 @@ export const useModelsStore = defineStore( const idToModel = shallowRef(Map()); const infos = computed(() => - idToModel.value.map(({ taskID, dateSaved, encoded }) => ({ + idToModel.value.map(({ taskID, dateSaved, dataType, encoded }) => ({ taskID, dateSaved, + dataType, storageSize: encoded.length / BEST_STORAGE.EFFICIENCY, })), ); @@ -42,6 +43,7 @@ export const useModelsStore = defineStore( idToModel.value = idToModel.value.set(id, { taskID, dateSaved, + dataType: model.datatype, encoded: await modelEncode(model), }); diff --git a/webapp/src/store/models/local_storage.ts b/webapp/src/store/models/local_storage.ts index 30f3b2788..d3a5183d6 100644 --- a/webapp/src/store/models/local_storage.ts +++ b/webapp/src/store/models/local_storage.ts @@ -4,6 +4,8 @@ import { useToaster } from "@/composables/toaster"; import type { Storage } from "./storage"; import type { ModelID, State } from "./types"; +import type { DataType } from "@epfml/discojs"; +import { isDataType } from "@epfml/discojs"; const toaster = useToaster(); @@ -27,7 +29,7 @@ export class LocalStorage implements Storage { return size < LocalStorage.#MAX_SIZE; }); - if (!state.idToModel.equals(keptModels)) + if (state.idToModel.size !== keptModels.size) toaster.warning( [ "Your browser' storage is too small to persist all models.", @@ -37,9 +39,10 @@ export class LocalStorage implements Storage { return JSON.stringify( keptModels - .map(({ taskID, dateSaved, encoded }) => ({ + .map(({ taskID, dateSaved, dataType, encoded }) => ({ taskID, dateSaved: dateSaved.getTime(), + dataType: dataType, // Uint8Array is very inefficiently encoded in JSON, Window.{atob,btoa} is broken // using hex encoding (base16) encoded: [...encoded] @@ -56,9 +59,10 @@ export class LocalStorage implements Storage { throw new Error("unexpected serialized state"); return { - idToModel: Map(raw).map(({ taskID, dateSaved, encoded }) => ({ + idToModel: Map(raw).map(({ taskID, dateSaved, dataType, encoded }) => ({ taskID, dateSaved: new Date(dateSaved), + dataType: dataType, encoded: Uint8Array.from( Range(0, encoded.length / 2).map((i) => Number.parseInt(encoded.slice(i * 2, i * 2 + 2), 16), @@ -75,6 +79,7 @@ export namespace LocalStorage { { taskID: string; dateSaved: number; + dataType: DataType; encoded: string; }, ] @@ -107,6 +112,7 @@ function isSerializedInfos(raw: unknown): raw is LocalStorage.Serialized[0][1] { const { taskID, encoded, + dataType, dateSaved, }: Partial> = raw; @@ -118,13 +124,15 @@ function isSerializedInfos(raw: unknown): raw is LocalStorage.Serialized[0][1] { // check for any character outside of the hex range encoded.match(/[^0-9a-f]/) === null ) || - typeof dateSaved !== "number" + typeof dateSaved !== "number" || + !isDataType(dataType) ) return false; const _: LocalStorage.Serialized[0][1] = { taskID, encoded, + dataType, dateSaved, } satisfies Record; diff --git a/webapp/src/store/models/opfs.ts b/webapp/src/store/models/opfs.ts index 51d50ef8d..c037952ae 100644 --- a/webapp/src/store/models/opfs.ts +++ b/webapp/src/store/models/opfs.ts @@ -2,7 +2,7 @@ import { Map } from "immutable"; import * as msgpack from "@msgpack/msgpack"; import type { IStorage } from "pinia-plugin-persistedstate-2"; -import { isEncoded } from "@epfml/discojs"; +import { isEncoded, isDataType } from "@epfml/discojs"; import type { Storage } from "./storage"; import { UNSUPPORTED_STORAGE } from "./storage"; @@ -98,18 +98,21 @@ function isSerializedInfos(raw: unknown): raw is OPFS.Serialized[0][1] { taskID, encoded, dateSaved, + dataType, }: Partial> = raw; if ( typeof taskID !== "string" || !isEncoded(encoded) || - !(dateSaved instanceof Date) + !(dateSaved instanceof Date) || + !isDataType(dataType) ) return false; const _: OPFS.Serialized[0][1] = { taskID, encoded, + dataType, dateSaved, } satisfies Record; diff --git a/webapp/src/store/models/types.ts b/webapp/src/store/models/types.ts index aa63c7214..8f7a652b9 100644 --- a/webapp/src/store/models/types.ts +++ b/webapp/src/store/models/types.ts @@ -1,12 +1,13 @@ import type { Map } from "immutable"; -import type { Encoded } from "@epfml/discojs"; +import type { Encoded, DataType } from "@epfml/discojs"; export type ModelID = number; export interface Infos { taskID: string; dateSaved: Date; + dataType: DataType; encoded: Encoded; } diff --git a/webapp/src/store/tasks.ts b/webapp/src/store/tasks.ts index b0d9806dd..ab9c5a96a 100644 --- a/webapp/src/store/tasks.ts +++ b/webapp/src/store/tasks.ts @@ -17,6 +17,8 @@ const TASKS_TO_FILTER_OUT = Set.of("cifar10"); export const useTasksStore = defineStore("tasks", () => { // 3-state variable used to test whether the tasks have been retrieved successfully, // if the retrieving failed, or if they are currently being loaded + // Use shallowRef instead of ref because ref deeply wraps the object and loses access + // to its private methods const tasks = shallowRef< "loading" | "failed" | Map> >("loading");