From 86cc64c929692e13292b3204827c05b07f742b64 Mon Sep 17 00:00:00 2001 From: Richard Gill Date: Sat, 6 Apr 2024 10:51:10 +0100 Subject: [PATCH] feat: (#3) --- .github/workflows/ci.yml | 7 + apps/www/src/lib/search.ts | 3 - eslint.config.mjs | 1 - packages/react/package.json | 3 +- .../src/components/LLMOutput/helper.test.tsx | 363 ++++++++++++++++++ .../react/src/components/LLMOutput/helper.ts | 166 ++++++++ .../react/src/components/LLMOutput/index.tsx | 23 ++ .../react/src/components/LLMOutput/types.ts | 28 ++ packages/react/src/components/index.ts | 1 + packages/react/src/dummy.test.ts | 7 - packages/react/src/hooks/index.ts | 1 + packages/react/src/index.ts | 1 - 12 files changed, 591 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/components/LLMOutput/helper.test.tsx create mode 100644 packages/react/src/components/LLMOutput/helper.ts create mode 100644 packages/react/src/components/LLMOutput/index.tsx create mode 100644 packages/react/src/components/LLMOutput/types.ts create mode 100644 packages/react/src/components/index.ts delete mode 100644 packages/react/src/dummy.test.ts create mode 100644 packages/react/src/hooks/index.ts delete mode 100644 packages/react/src/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb1beb06..d3f13200 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,10 @@ jobs: - uses: actions/checkout@v3 - uses: ./.github/shared - run: pnpm typecheck + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/shared + - run: pnpm test diff --git a/apps/www/src/lib/search.ts b/apps/www/src/lib/search.ts index 8a8a93b1..0e6c088f 100644 --- a/apps/www/src/lib/search.ts +++ b/apps/www/src/lib/search.ts @@ -1,7 +1,5 @@ export type Pagefind = { - // eslint-disable-next-line no-unused-vars search: (query: string) => Promise; - // eslint-disable-next-line no-unused-vars debouncedSearch: (query: string) => Promise; }; @@ -39,7 +37,6 @@ export type PagefindDocument = { }; declare global { - // eslint-disable-next-line no-unused-vars interface Window { pagefind: Pagefind; } diff --git a/eslint.config.mjs b/eslint.config.mjs index ba0ef7d1..ddb87f1c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,7 +58,6 @@ export default [ ...js.configs.recommended.plugins, }, rules: { - "no-unused-vars": ["error", { varsIgnorePattern: "React" }], "prefer-arrow-callback": "error", "prefer-arrow/prefer-arrow-functions": [ "error", diff --git a/packages/react/package.json b/packages/react/package.json index cf209b9c..c1e10d53 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -8,7 +8,8 @@ "types": "./dist/index.d.ts", "exports": { "./package.json": "./package.json", - ".": "./src/index.ts" + "components": "./src/components/index.ts", + "hooks": "./src/hooks/index.ts" }, "files": [ "dist" diff --git a/packages/react/src/components/LLMOutput/helper.test.tsx b/packages/react/src/components/LLMOutput/helper.test.tsx new file mode 100644 index 00000000..6625cd8b --- /dev/null +++ b/packages/react/src/components/LLMOutput/helper.test.tsx @@ -0,0 +1,363 @@ +import { describe, expect, test } from "vitest"; +import { matchComponents } from "./helper"; +import { + ComponentMatch, + LLMOutputComponent, + LLMOutputReactComponent, + MaybeMatch, +} from "./types"; + +const fallbackComponent = () => null; +const component1 = () =>
1
; +const component2 = () =>
2
; +const component3 = () =>
3
; +const component4 = () =>
4
; + +const noMatch = () => undefined; + +const neverMatchComponent: LLMOutputComponent = { + component: component1, + fullMatch: noMatch, + partialMatch: noMatch, + partialComponent: component1, +}; + +const matchString = (inputString: string, target: string): MaybeMatch => { + const startIndex = inputString.indexOf(target); + if (startIndex === -1) { + return undefined; + } + return { + startIndex, + endIndex: startIndex + target.length, + match: target, + }; +}; + +const fullMatchComponent = ( + component: LLMOutputReactComponent, + target: string, +): LLMOutputComponent => { + return { + component, + fullMatch: (output) => matchString(output, target), + partialMatch: noMatch, + partialComponent: component1, + }; +}; + +type TestCase = { + name: string; + llmOutput: string; + components: LLMOutputComponent[]; + fallbackComponent: LLMOutputReactComponent; + expected: ComponentMatch[]; +}; + +describe("matchComponents", () => { + const testCases: TestCase[] = [ + { + name: "no components is full fallback match", + llmOutput: "helloWorld", + components: [], + fallbackComponent, + expected: [ + { + component: fallbackComponent, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 0, + }, + ], + }, + { + name: "no matching components is full fallback match", + llmOutput: "helloWorld", + components: [neverMatchComponent], + fallbackComponent, + expected: [ + { + component: fallbackComponent, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 1, + }, + ], + }, + { + name: "first component full matches whole input", + llmOutput: "helloWorld", + components: [ + { + component: component1, + fullMatch: (output) => matchString(output, "helloWorld"), + partialMatch: noMatch, + partialComponent: component2, + }, + ], + fallbackComponent, + expected: [ + { + component: component1, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 0, + }, + ], + }, + { + name: "first component full matches begginning of input", + llmOutput: "helloWorld world", + components: [ + { + component: component1, + fullMatch: (output) => matchString(output, "helloWorld"), + partialMatch: noMatch, + partialComponent: component2, + }, + ], + fallbackComponent, + expected: [ + { + component: component1, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 0, + }, + { + component: fallbackComponent, + match: { match: " world", startIndex: 10, endIndex: 16 }, + priority: 1, + }, + ], + }, + { + name: "first component full matches end of input", + llmOutput: "helloWorld world", + components: [ + { + component: component1, + fullMatch: (output) => matchString(output, " world"), + partialMatch: noMatch, + partialComponent: component2, + }, + ], + fallbackComponent, + expected: [ + { + component: fallbackComponent, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 1, + }, + { + component: component1, + match: { match: " world", startIndex: 10, endIndex: 16 }, + priority: 0, + }, + ], + }, + { + name: "first component full matches middle of input", + llmOutput: "helloWorld world", + components: [ + { + component: component1, + fullMatch: (output) => matchString(output, "oWo"), + partialMatch: noMatch, + partialComponent: component2, + }, + ], + fallbackComponent, + expected: [ + { + component: fallbackComponent, + match: { match: "hell", startIndex: 0, endIndex: 4 }, + priority: 1, + }, + { + component: component1, + match: { match: "oWo", startIndex: 4, endIndex: 7 }, + priority: 0, + }, + { + component: fallbackComponent, + match: { match: "rld world", startIndex: 7, endIndex: 16 }, + priority: 1, + }, + ], + }, + { + name: "second component full matches begginning of input", + llmOutput: "helloWorld world", + components: [ + neverMatchComponent, + { + component: component3, + fullMatch: (output) => matchString(output, "helloWorld"), + partialMatch: noMatch, + partialComponent: component4, + }, + ], + fallbackComponent, + expected: [ + { + component: component3, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 1, + }, + { + component: fallbackComponent, + match: { match: " world", startIndex: 10, endIndex: 16 }, + priority: 2, + }, + ], + }, + { + name: "first full match takes priority over second identical full match", + llmOutput: "helloWorld", + components: [ + { + component: component1, + fullMatch: (output) => matchString(output, "hello"), + partialMatch: noMatch, + partialComponent: component2, + }, + { + component: component3, + fullMatch: (output) => matchString(output, "hello"), + partialMatch: noMatch, + partialComponent: component4, + }, + ], + fallbackComponent, + expected: [ + { + component: component1, + match: { match: "hello", startIndex: 0, endIndex: 5 }, + priority: 0, + }, + { + component: fallbackComponent, + match: { match: "World", startIndex: 5, endIndex: 10 }, + priority: 2, + }, + ], + }, + { + name: "first full match takes priority over second overlapping full match", + llmOutput: "helloWorld", + components: [ + { + component: component1, + fullMatch: (output) => matchString(output, "hello"), + partialMatch: noMatch, + partialComponent: component2, + }, + { + component: component3, + fullMatch: (output) => matchString(output, "ell"), + partialMatch: noMatch, + partialComponent: component4, + }, + ], + fallbackComponent, + expected: [ + { + component: component1, + match: { match: "hello", startIndex: 0, endIndex: 5 }, + priority: 0, + }, + { + component: fallbackComponent, + match: { match: "World", startIndex: 5, endIndex: 10 }, + priority: 2, + }, + ], + }, + { + name: "first component partial matches whole input", + llmOutput: "helloWorld", + components: [ + { + component: component1, + fullMatch: noMatch, + partialMatch: (output) => matchString(output, "helloWorld"), + partialComponent: component2, + }, + ], + fallbackComponent, + expected: [ + { + component: component2, + match: { match: "helloWorld", startIndex: 0, endIndex: 10 }, + priority: 0, + }, + ], + }, + { + name: "first component partial matches end of input", + llmOutput: "helloWorld", + components: [ + { + component: component1, + fullMatch: noMatch, + partialMatch: (output) => matchString(output, "World"), + partialComponent: component2, + }, + ], + fallbackComponent, + expected: [ + { + component: fallbackComponent, + match: { match: "hello", startIndex: 0, endIndex: 5 }, + priority: 1, + }, + { + component: component2, + match: { match: "World", startIndex: 5, endIndex: 10 }, + priority: 0, + }, + ], + }, + { + name: "partial match after full matches", + llmOutput: "helloWorld", + components: [ + { + component: component1, + fullMatch: noMatch, + partialMatch: (output) => matchString(output, "World"), + partialComponent: component2, + }, + { + component: component3, + fullMatch: (output) => matchString(output, "hello"), + partialMatch: noMatch, + partialComponent: component4, + }, + ], + fallbackComponent, + expected: [ + { + component: component3, + match: { match: "hello", startIndex: 0, endIndex: 5 }, + priority: 1, + }, + { + component: component2, + match: { match: "World", startIndex: 5, endIndex: 10 }, + priority: 0, + }, + ], + }, + ]; + + testCases.forEach( + ({ name, llmOutput, components, fallbackComponent, expected }) => { + test(name, () => { + const matches = matchComponents( + llmOutput, + components, + fallbackComponent, + ); + expect(matches).toEqual(expected); + }); + }, + ); +}); diff --git a/packages/react/src/components/LLMOutput/helper.ts b/packages/react/src/components/LLMOutput/helper.ts new file mode 100644 index 00000000..a0c89ad1 --- /dev/null +++ b/packages/react/src/components/LLMOutput/helper.ts @@ -0,0 +1,166 @@ +import { + ComponentMatch, + LLMOutputComponent, + LLMOutputReactComponent, + Match, +} from "./types"; + +const fullMatchesForComponent = ( + llmOutput: string, + component: LLMOutputComponent, + priority: number, +): ComponentMatch[] => { + const matches: ComponentMatch[] = []; + let index = 0; + while (index < llmOutput.length) { + const nextMatch = component.fullMatch(llmOutput.slice(index)); + if (nextMatch) { + matches.push({ + component: component.component, + match: nextMatch, + priority, + }); + index += nextMatch.endIndex; + } else { + return matches; + } + } + return matches; +}; + +const highestPriorityNonOverlappingMatches = ( + matches: ComponentMatch[], +): ComponentMatch[] => { + return matches.filter((match) => { + const higherPriorityMatches = matches.filter( + (m) => m.priority < match.priority, + ); + return !higherPriorityMatches.some((m) => + isOverlapping(m.match, match.match), + ); + }); +}; + +const byMatchStartIndex = ( + match1: ComponentMatch, + match2: ComponentMatch, +): number => match1.match.startIndex - match2.match.startIndex; + +const isOverlapping = (match1: Match, match2: Match): boolean => { + return ( + (match1.startIndex >= match2.startIndex && + match1.startIndex < match2.endIndex) || // match1 starts inside match2 + (match1.endIndex > match2.startIndex && + match1.endIndex <= match2.endIndex) || // match1 ends inside match2 + (match2.startIndex >= match1.startIndex && + match2.startIndex < match1.endIndex) || // match2 starts inside match1 + (match2.endIndex > match1.startIndex && match2.endIndex <= match1.endIndex) // match2 ends inside match1 + ); +}; + +const findPartialMatch = ( + llmOutput: string, + currentIndex: number, + components: LLMOutputComponent[], +): ComponentMatch | undefined => { + for (const [priority, component] of components.entries()) { + const partialMatch = component.partialMatch(llmOutput.slice(currentIndex)); + if (partialMatch) { + return { + component: component.partialComponent, + match: { + match: partialMatch.match, + startIndex: partialMatch.startIndex + currentIndex, + endIndex: partialMatch.endIndex + currentIndex, + }, + priority, + }; + } + } + return undefined; +}; + +const fallbacksInGaps = ( + componentMatches: ComponentMatch[], + llmOutput: string, + fallbackPriority: number, + fallbackComponent: LLMOutputReactComponent, +): ComponentMatch[] => { + const fallbacks = componentMatches + .map((match, index) => { + const previousMatchEndIndex = + index === 0 ? 0 : componentMatches[index - 1].match.endIndex; + if (previousMatchEndIndex < match.match.startIndex) { + return { + component: fallbackComponent, + match: { + startIndex: previousMatchEndIndex, + endIndex: match.match.startIndex, + match: llmOutput.slice( + previousMatchEndIndex, + match.match.startIndex, + ), + }, + priority: fallbackPriority, + }; + } + return undefined; + }) + .filter((match) => match !== undefined) as ComponentMatch[]; + + // Add last fallback that reaches to end of output + const lastMatchEndIndex = + componentMatches.length > 0 + ? componentMatches[componentMatches.length - 1].match.endIndex + : 0; + + if (lastMatchEndIndex < llmOutput.length) { + fallbacks.push({ + component: fallbackComponent, + match: { + startIndex: lastMatchEndIndex, + endIndex: llmOutput.length, + match: llmOutput.slice(lastMatchEndIndex, llmOutput.length), + }, + priority: fallbackPriority, + }); + } + return fallbacks; +}; + +export const matchComponents = ( + llmOutput: string, + components: LLMOutputComponent[], + fallbackComponent: LLMOutputReactComponent, +): ComponentMatch[] => { + const allFullMatches = components.flatMap((component, priority) => + fullMatchesForComponent(llmOutput, component, priority), + ); + const matches = highestPriorityNonOverlappingMatches(allFullMatches); + matches.sort(byMatchStartIndex); + + const lastMatchEndIndex = + matches.length > 0 ? matches[matches.length - 1].match.endIndex : 0; + + const partialMatch = findPartialMatch( + llmOutput, + lastMatchEndIndex, + components, + ); + + if (partialMatch) { + matches.push(partialMatch); + } + const fallBacks = fallbacksInGaps( + matches, + llmOutput, + components.length, + fallbackComponent, + ); + + for (const fallBack of fallBacks) { + matches.push(fallBack); + } + matches.sort(byMatchStartIndex); + return matches; +}; diff --git a/packages/react/src/components/LLMOutput/index.tsx b/packages/react/src/components/LLMOutput/index.tsx new file mode 100644 index 00000000..0005a3b2 --- /dev/null +++ b/packages/react/src/components/LLMOutput/index.tsx @@ -0,0 +1,23 @@ +import { matchComponents } from "./helper"; +import { LLMOutputComponent, LLMOutputReactComponent } from "./types"; + +export type LLMOutputProps = { + llmOutput: string; + components: LLMOutputComponent[]; + fallbackComponent: LLMOutputReactComponent; +}; + +export const LLMOutput: React.FC = ({ + llmOutput, + components, + fallbackComponent, +}) => { + const matches = matchComponents(llmOutput, components, fallbackComponent); + return ( + <> + {matches.map(({ component: Component, match }, index) => { + return ; + })} + + ); +}; diff --git a/packages/react/src/components/LLMOutput/types.ts b/packages/react/src/components/LLMOutput/types.ts new file mode 100644 index 00000000..e5113a36 --- /dev/null +++ b/packages/react/src/components/LLMOutput/types.ts @@ -0,0 +1,28 @@ +export type Match = { + startIndex: number; + endIndex: number; + match: string; +}; + +export type MaybeMatch = Match | undefined; + +export type LLMOutputReactComponent = React.FC<{ llmOutput: string }>; + +export type LLMOutputComponent = { + partialMatch: (llmOutput: string) => MaybeMatch; + fullMatch: (llmOutput: string) => MaybeMatch; + component: LLMOutputReactComponent; + partialComponent: LLMOutputReactComponent; +}; + +export type LLMOutputProps = { + llmOutput: string; + components: LLMOutputComponent[]; + fallbackComponent: LLMOutputReactComponent; +}; + +export type ComponentMatch = { + component: LLMOutputReactComponent; + match: Match; + priority: number; +}; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts new file mode 100644 index 00000000..a899f0c5 --- /dev/null +++ b/packages/react/src/components/index.ts @@ -0,0 +1 @@ +export { LLMOutput } from "./LLMOutput"; diff --git a/packages/react/src/dummy.test.ts b/packages/react/src/dummy.test.ts deleted file mode 100644 index 498785f5..00000000 --- a/packages/react/src/dummy.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, expect, it } from "vitest"; - -describe("dummy", () => { - it("dummy", () => { - expect(1 + 1).toBe(2); - }); -}); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 00000000..d91f41f6 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1 @@ +export { useStreamExample } from "./useStreamExample"; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts deleted file mode 100644 index f33a5fa5..00000000 --- a/packages/react/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useStreamExample } from "./hooks/useStreamExample";