From b87a4abdbb83f54e46907c6fa1ee69907e2c8bd4 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 14 Nov 2025 21:32:13 +0100 Subject: [PATCH 1/8] initial autocomplete attempt --- examples/09-ai/01-minimal/src/App.tsx | 2 + .../SuggestionMenu/SuggestionPlugin.ts | 17 +- packages/xl-ai/src/index.ts | 1 + .../xl-ai/src/plugins/AutoCompletePlugin.ts | 437 ++++++++++++++++++ packages/xl-ai/src/style.css | 6 + 5 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 packages/xl-ai/src/plugins/AutoCompletePlugin.ts diff --git a/examples/09-ai/01-minimal/src/App.tsx b/examples/09-ai/01-minimal/src/App.tsx index 8c0ce912a2..8cc25c07db 100644 --- a/examples/09-ai/01-minimal/src/App.tsx +++ b/examples/09-ai/01-minimal/src/App.tsx @@ -14,6 +14,7 @@ import { import { AIMenuController, AIToolbarButton, + createAIAutoCompleteExtension, createAIExtension, getAISlashMenuItems, } from "@blocknote/xl-ai"; @@ -41,6 +42,7 @@ export default function App() { api: `${BASE_URL}/regular/streamText`, }), }), + createAIAutoCompleteExtension(), ], // We set some initial content for demo purposes initialContent: [ diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index 0c4bb7f4a6..10b5ee8818 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -290,7 +290,7 @@ export class SuggestionMenuProseMirrorPlugin< }, props: { - handleTextInput(view, from, to, text) { + handleTextInput(view, from, to, text, deflt) { // only on insert if (from === to) { const doc = view.state.doc; @@ -301,18 +301,21 @@ export class SuggestionMenuProseMirrorPlugin< : text; if (str === snippet) { - view.dispatch(view.state.tr.insertText(text)); view.dispatch( - view.state.tr - .setMeta(suggestionMenuPluginKey, { - triggerCharacter: snippet, - }) - .scrollIntoView(), + deflt().setMeta(suggestionMenuPluginKey, { + triggerCharacter: snippet, + }), ); return true; } } } + if (this.getState(view.state)) { + // when menu is open, we dispatch the default transaction + // and return true so that other event handlers (i.e.: AI AutoComplete) are not triggered + view.dispatch(deflt()); + return true; + } return false; }, diff --git a/packages/xl-ai/src/index.ts b/packages/xl-ai/src/index.ts index 843bb17d03..7db8f0650d 100644 --- a/packages/xl-ai/src/index.ts +++ b/packages/xl-ai/src/index.ts @@ -11,4 +11,5 @@ export * from "./components/AIMenu/PromptSuggestionMenu.js"; export * from "./components/FormattingToolbar/AIToolbarButton.js"; export * from "./components/SuggestionMenu/getAISlashMenuItems.js"; export * from "./i18n/dictionary.js"; +export * from "./plugins/AutoCompletePlugin.js"; export * from "./streamTool/index.js"; diff --git a/packages/xl-ai/src/plugins/AutoCompletePlugin.ts b/packages/xl-ai/src/plugins/AutoCompletePlugin.ts new file mode 100644 index 0000000000..7d11e9642d --- /dev/null +++ b/packages/xl-ai/src/plugins/AutoCompletePlugin.ts @@ -0,0 +1,437 @@ +import { + BlockNoteEditor, + BlockNoteExtension, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +export type AutoCompleteState = + | { + autoCompleteSuggestion: AutoCompleteSuggestion; + } + | undefined; + +// class AutoCompleteView< +// BSchema extends BlockSchema, +// I extends InlineContentSchema, +// S extends StyleSchema, +// > implements PluginView +// { +// public state?: AutoCompleteState; + +// private rootEl?: Document | ShadowRoot; +// // pluginState: AutoCompleteState; + +// constructor( +// private readonly editor: BlockNoteEditor, +// public readonly view: EditorView, +// ) { +// // this.pluginState = undefined; +// } +// } + +const autoCompletePluginKey = new PluginKey<{ isUserInput: boolean }>( + "AutoCompletePlugin", +); + +type AutoCompleteSuggestion = { + position: number; + suggestion: string; +}; + +async function fetchAutoCompleteSuggestions( + state: EditorState, + _signal: AbortSignal, +) { + console.log("fetch"); + return [ + { + position: state.selection.from, + suggestion: "Hello World", + }, + { + position: state.selection.from, + suggestion: "Hello Planet", + }, + ]; +} + +function getMatchingSuggestions( + autoCompleteSuggestions: AutoCompleteSuggestion[], + state: EditorState, +): AutoCompleteSuggestion[] { + return autoCompleteSuggestions + .map((suggestion) => { + if (suggestion.position > state.selection.from) { + return false; + } + + if ( + !state.doc + .resolve(suggestion.position) + .sameParent(state.selection.$from) + ) { + return false; + } + + const text = state.doc.textBetween( + suggestion.position, + state.selection.from, + ); + if ( + suggestion.suggestion.startsWith(text) && + suggestion.suggestion.length > text.length + ) { + return { + position: suggestion.position, + suggestion: suggestion.suggestion.slice(text.length), + }; + } + return false; + }) + .filter((suggestion) => suggestion !== false); +} + +export class AutoCompleteProseMirrorPlugin< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, +> extends BlockNoteExtension { + public static key() { + return "suggestionMenu"; + } + + public get priority(): number | undefined { + return 1000000; // should be lower (e.g.: -1000 to be below suggestion menu, but that currently breaks Tab) + } + + // private view: AutoCompleteView | undefined; + + // private view: EditorView | undefined; + private autoCompleteSuggestions: AutoCompleteSuggestion[] = []; + + private debounceFetchSuggestions = debounceWithAbort( + async (state: EditorState, signal: AbortSignal) => { + // fetch suggestions + const autoCompleteSuggestions = await fetchAutoCompleteSuggestions( + state, + signal, + ); + + // TODO: map positions? + + if (signal.aborted) { + return; + } + + this.autoCompleteSuggestions = autoCompleteSuggestions; + this.editor.transact((tr) => { + tr.setMeta(autoCompletePluginKey, { + autoCompleteSuggestions, + }); + }); + }, + ); + + constructor(private readonly editor: BlockNoteEditor) { + super(); + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + this.addProsemirrorPlugin( + new Plugin({ + key: autoCompletePluginKey, + + // view: (view) => { + // this.view = new AutoCompleteView(editor, view); + // return this.view; + // }, + + state: { + // Initialize the plugin's internal state. + init(): AutoCompleteState { + return undefined; + }, + + // Apply changes to the plugin state from an editor transaction. + apply: ( + transaction, + prev, + _oldState, + newState, + ): AutoCompleteState => { + // selection is active, no autocomplete + if (newState.selection.from !== newState.selection.to) { + this.debounceFetchSuggestions.cancel(); + return undefined; + } + + // Are there matching suggestions? + const matchingSuggestions = getMatchingSuggestions( + this.autoCompleteSuggestions, + newState, + ); + + if (matchingSuggestions.length > 0) { + this.debounceFetchSuggestions.cancel(); + return { + autoCompleteSuggestion: matchingSuggestions[0], + }; + } + + // No matching suggestions, if isUserInput is true, debounce fetch suggestions + if (transaction.getMeta(autoCompletePluginKey)?.isUserInput) { + this.debounceFetchSuggestions(newState).catch((error) => { + /* eslint-disable-next-line no-console */ + console.error(error); + }); + } else { + // clear suggestions + this.autoCompleteSuggestions = []; + } + return undefined; + + // Ignore transactions in code blocks. + // if (transaction.selection.$from.parent.type.spec.code) { + // return prev; + // } + + // // Either contains the trigger character if the menu should be shown, + // // or null if it should be hidden. + // const suggestionPluginTransactionMeta: { + // triggerCharacter: string; + // deleteTriggerCharacter?: boolean; + // ignoreQueryLength?: boolean; + // } | null = transaction.getMeta(autoCompletePluginKey); + + // if ( + // typeof suggestionPluginTransactionMeta === "object" && + // suggestionPluginTransactionMeta !== null + // ) { + // if (prev) { + // // Close the previous menu if it exists + // this.closeMenu(); + // } + // const trackedPosition = trackPosition( + // editor, + // newState.selection.from - + // // Need to account for the trigger char that was inserted, so we offset the position by the length of the trigger character. + // suggestionPluginTransactionMeta.triggerCharacter.length, + // ); + // return { + // triggerCharacter: + // suggestionPluginTransactionMeta.triggerCharacter, + // deleteTriggerCharacter: + // suggestionPluginTransactionMeta.deleteTriggerCharacter !== + // false, + // // When reading the queryStartPos, we offset the result by the length of the trigger character, to make it easy on the caller + // queryStartPos: () => + // trackedPosition() + + // suggestionPluginTransactionMeta.triggerCharacter.length, + // query: "", + // decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, + // ignoreQueryLength: + // suggestionPluginTransactionMeta?.ignoreQueryLength, + // }; + // } + + // // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated. + // if (prev === undefined) { + // return prev; + // } + + // // Checks if the menu should be hidden. + // if ( + // // Highlighting text should hide the menu. + // newState.selection.from !== newState.selection.to || + // // Transactions with plugin metadata should hide the menu. + // suggestionPluginTransactionMeta === null || + // // Certain mouse events should hide the menu. + // // TODO: Change to global mousedown listener. + // transaction.getMeta("focus") || + // transaction.getMeta("blur") || + // transaction.getMeta("pointer") || + // // Moving the caret before the character which triggered the menu should hide it. + // (prev.triggerCharacter !== undefined && + // newState.selection.from < prev.queryStartPos()) || + // // Moving the caret to a new block should hide the menu. + // !newState.selection.$from.sameParent( + // newState.doc.resolve(prev.queryStartPos()), + // ) + // ) { + // return undefined; + // } + + // const next = { ...prev }; + // // here we wi + // // Updates the current query. + // next.query = newState.doc.textBetween( + // prev.queryStartPos(), + // newState.selection.from, + // ); + + // return next; + }, + }, + + props: { + handleKeyDown(view, event) { + if (event.key === "Tab") { + // TODO (discuss with Nick): + // Plugin priority needs to be below suggestion menu, so no auto complete is triggered when the suggestion menu is open + // However, Plugin priority needs to be above other Tab handlers (because now indentation will be wrongly prioritized over auto complete) + const autoCompleteState = this.getState(view.state); + + if (autoCompleteState) { + // insert suggestion + view.dispatch( + view.state.tr + .insertText( + autoCompleteState.autoCompleteSuggestion.suggestion, + ) + .setMeta(autoCompletePluginKey, { isUserInput: true }), // isUserInput true to trigger new fetch + ); + return true; + } + + // if tab to suggest is enabled (TODO: make configurable) + view.dispatch( + view.state.tr.setMeta(autoCompletePluginKey, { + isUserInput: true, + }), + ); + return true; + } + + if (event.key === "Escape") { + self.autoCompleteSuggestions = []; + self.debounceFetchSuggestions.cancel(); + view.dispatch(view.state.tr.setMeta(autoCompletePluginKey, {})); + return true; + } + + return false; + }, + handleTextInput(view, _from, _to, _text, deflt) { + const tr = deflt(); + tr.setMeta(autoCompletePluginKey, { + isUserInput: true, + }); + view.dispatch(tr); + return true; + }, + + // Setup decorator on the currently active suggestion. + decorations(state) { + const autoCompleteState: AutoCompleteState = this.getState(state); + + if (!autoCompleteState) { + return null; + } + + console.log(autoCompleteState); + // Creates an inline decoration around the trigger character. + return DecorationSet.create(state.doc, [ + Decoration.widget( + state.selection.from, + renderAutoCompleteSuggestion( + autoCompleteState.autoCompleteSuggestion.suggestion, + ), + {}, + ), + ]); + }, + }, + }), + ); + } +} + +function renderAutoCompleteSuggestion(suggestion: string) { + const element = document.createElement("span"); + element.classList.add("bn-autocomplete-decorator"); + element.textContent = suggestion; + return element; +} + +export function debounceWithAbort( + fn: (...args: [...T, AbortSignal]) => Promise | R, + delay = 300, // TODO: configurable +) { + let timeoutId: ReturnType | null = null; + let controller: AbortController | null = null; + + const debounced = (...args: T): Promise => { + // Clear pending timeout + if (timeoutId) { + clearTimeout(timeoutId); + } + + // Abort any in-flight execution + if (controller) { + controller.abort(); + } + + controller = new AbortController(); + const signal = controller.signal; + + return new Promise((resolve, reject) => { + timeoutId = setTimeout(async () => { + try { + const result = await fn(...args, signal); + resolve(result); + } catch (err) { + reject(err); + } + }, delay); + }); + }; + + // External cancel method + debounced.cancel = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = null; + + if (controller) { + controller.abort(); + } + controller = null; + }; + + return debounced; +} + +// Add a type for the cancel method +export interface DebouncedFunction { + (...args: T): Promise; + cancel(): void; +} + +// TODO: more to blocknote API? +// TODO: test with Collaboration edits +// TODO: compare kilocode / cline etc +// TODO: think about advanced scenarios (e.g.: multiple suggestions, etc.) + +/** + * Create a new AIExtension instance, this can be passed to the BlockNote editor via the `extensions` option + */ +export function createAIAutoCompleteExtension() { + // options: ConstructorParameters[1], + return (editor: BlockNoteEditor) => { + return new AutoCompleteProseMirrorPlugin(editor); + }; +} + +/** + * Return the AIExtension instance from the editor + */ +export function getAIAutoCompleteExtension( + editor: BlockNoteEditor, +) { + return editor.extension(AutoCompleteProseMirrorPlugin); +} diff --git a/packages/xl-ai/src/style.css b/packages/xl-ai/src/style.css index 4b7558d518..5547d181b3 100644 --- a/packages/xl-ai/src/style.css +++ b/packages/xl-ai/src/style.css @@ -31,3 +31,9 @@ del, text-decoration: line-through; text-decoration-thickness: 1px; } + +.bn-autocomplete-decorator { + width: 50px; + height: 50px; + color: var(--bn-colors-side-menu); +} From 656d9eb57ad5423bade2a5732f66ca2e2b428851 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 14 Nov 2025 23:05:49 +0100 Subject: [PATCH 2/8] connect api --- packages/xl-ai-server/src/index.ts | 2 + .../xl-ai-server/src/routes/autocomplete.ts | 46 +++++++++++++++++ .../xl-ai/src/plugins/AutoCompletePlugin.ts | 49 +++++++++++++------ 3 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 packages/xl-ai-server/src/routes/autocomplete.ts diff --git a/packages/xl-ai-server/src/index.ts b/packages/xl-ai-server/src/index.ts index f66ef6738e..375252e1e3 100644 --- a/packages/xl-ai-server/src/index.ts +++ b/packages/xl-ai-server/src/index.ts @@ -5,6 +5,7 @@ import { cors } from "hono/cors"; import { existsSync, readFileSync } from "node:fs"; import { createSecureServer } from "node:http2"; import { Agent, setGlobalDispatcher } from "undici"; +import { autocompleteRoute } from "./routes/autocomplete.js"; import { modelPlaygroundRoute } from "./routes/model-playground/index.js"; import { objectGenerationRoute } from "./routes/objectGeneration.js"; import { proxyRoute } from "./routes/proxy.js"; @@ -37,6 +38,7 @@ app.route("/ai/proxy", proxyRoute); app.route("/ai/object-generation", objectGenerationRoute); app.route("/ai/server-promptbuilder", serverPromptbuilderRoute); app.route("/ai/model-playground", modelPlaygroundRoute); +app.route("/ai/autocomplete", autocompleteRoute); const http2 = existsSync("localhost.pem"); serve( diff --git a/packages/xl-ai-server/src/routes/autocomplete.ts b/packages/xl-ai-server/src/routes/autocomplete.ts new file mode 100644 index 0000000000..a7099a5f75 --- /dev/null +++ b/packages/xl-ai-server/src/routes/autocomplete.ts @@ -0,0 +1,46 @@ +import { createGroq } from "@ai-sdk/groq"; +import { generateText } from "ai"; +import { Hono } from "hono"; + +export const autocompleteRoute = new Hono(); + +// Setup your model +// const model = createOpenAI({ +// apiKey: process.env.OPENAI_API_KEY, +// })("gpt-4.1-nano"); + +const model = createGroq({ + apiKey: process.env.GROQ_API_KEY, +})("openai/gpt-oss-20b"); + +// Use `streamText` to stream text responses from the LLM +autocompleteRoute.post("/generateText", async (c) => { + const { text } = await c.req.json(); + + const result = await generateText({ + model, + system: `You are a writing assistant. Predict and generate the most likely next part of the text. +- separate suggestions by newlines +- max 3 suggestions +- keep it short, max 5 words per suggestion +- don't include other text (or explanations) +- ONLY return the text to be appended. Your suggestion will EXACTLY replace [SUGGESTION_HERE]. +- DONT include the original text / characters (prefix) +- add a space (or other relevant punctuation) before the suggestion if starting a new word`, + messages: [ + { + role: "user", + content: `Complete the following text: + ${text}[SUGGESTION_HERE]`, + }, + ], + abortSignal: c.req.raw.signal, + }); + + return c.json({ + suggestions: result.text + .split("\n") + .map((suggestion) => suggestion.trimEnd()) + .filter((suggestion) => suggestion.trim().length > 0), + }); +}); diff --git a/packages/xl-ai/src/plugins/AutoCompletePlugin.ts b/packages/xl-ai/src/plugins/AutoCompletePlugin.ts index 7d11e9642d..be7d767ab5 100644 --- a/packages/xl-ai/src/plugins/AutoCompletePlugin.ts +++ b/packages/xl-ai/src/plugins/AutoCompletePlugin.ts @@ -46,17 +46,34 @@ async function fetchAutoCompleteSuggestions( state: EditorState, _signal: AbortSignal, ) { - console.log("fetch"); - return [ - { - position: state.selection.from, - suggestion: "Hello World", - }, + // TODO: options to get block json until selection + const text = state.doc.textBetween( + state.selection.from - 300, + state.selection.from, + ); + + const response = await fetch( + `https://localhost:3000/ai/autocomplete/generateText`, { - position: state.selection.from, - suggestion: "Hello Planet", + method: "POST", + body: JSON.stringify({ text }), }, - ]; + ); + const data = await response.json(); + return data.suggestions.map((suggestion: string) => ({ + position: state.selection.from, + suggestion: suggestion, + })); + // return [ + // { + // position: state.selection.from, + // suggestion: "Hello World", + // }, + // { + // position: state.selection.from, + // suggestion: "Hello Planet", + // }, + // ]; } function getMatchingSuggestions( @@ -136,7 +153,10 @@ export class AutoCompleteProseMirrorPlugin< }, ); - constructor(private readonly editor: BlockNoteEditor) { + constructor( + private readonly editor: BlockNoteEditor, + options: {}, + ) { super(); // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -416,14 +436,15 @@ export interface DebouncedFunction { // TODO: test with Collaboration edits // TODO: compare kilocode / cline etc // TODO: think about advanced scenarios (e.g.: multiple suggestions, etc.) - +// TODO: double tap -> extra long /** * Create a new AIExtension instance, this can be passed to the BlockNote editor via the `extensions` option */ -export function createAIAutoCompleteExtension() { - // options: ConstructorParameters[1], +export function createAIAutoCompleteExtension( + options: ConstructorParameters[1], +) { return (editor: BlockNoteEditor) => { - return new AutoCompleteProseMirrorPlugin(editor); + return new AutoCompleteProseMirrorPlugin(editor, options); }; } From 43da4f0558028a1edf6d8191666837c2062457df Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 16 Nov 2025 16:10:18 +0100 Subject: [PATCH 3/8] initial agent demo --- agent-demo/.gitignore | 41 + agent-demo/README.md | 25 + agent-demo/app/api/chat/route.ts | 24 + agent-demo/app/assistant.tsx | 309 + agent-demo/app/document.tsx | 354 + agent-demo/app/favicon.ico | Bin 0 -> 25931 bytes agent-demo/app/globals.css | 156 + agent-demo/app/layout.tsx | 34 + agent-demo/app/page.tsx | 5 + agent-demo/assistant-ui-feedback.md | 3 + agent-demo/components.json | 21 + .../assistant-ui/assistant-modal.tsx | 65 + .../components/assistant-ui/attachment.tsx | 235 + .../components/assistant-ui/markdown-text.tsx | 228 + .../components/assistant-ui/thread-list.tsx | 95 + agent-demo/components/assistant-ui/thread.tsx | 447 + .../assistant-ui/threadlist-sidebar.tsx | 72 + .../components/assistant-ui/tool-fallback.tsx | 46 + .../assistant-ui/tooltip-icon-button.tsx | 42 + agent-demo/components/ui/avatar.tsx | 53 + agent-demo/components/ui/breadcrumb.tsx | 109 + agent-demo/components/ui/button.tsx | 59 + agent-demo/components/ui/dialog.tsx | 143 + agent-demo/components/ui/input.tsx | 21 + agent-demo/components/ui/separator.tsx | 28 + agent-demo/components/ui/sheet.tsx | 139 + agent-demo/components/ui/sidebar.tsx | 726 ++ agent-demo/components/ui/skeleton.tsx | 13 + agent-demo/components/ui/tooltip.tsx | 61 + agent-demo/eslint.config.mjs | 16 + agent-demo/hooks/use-mobile.ts | 21 + agent-demo/lib/utils.ts | 6 + agent-demo/next.config.ts | 7 + agent-demo/package-lock.json | 9642 +++++++++++++++++ agent-demo/package.json | 71 + agent-demo/postcss.config.mjs | 5 + agent-demo/tsconfig.json | 27 + .../extensions/Suggestions/SuggestionMarks.ts | 6 +- packages/xl-ai/package.json | 4 +- packages/xl-ai/src/index.ts | 1 + .../xl-ai/src/plugins/AutoCompletePlugin.ts | 4 +- packages/xl-ai/src/streamTool/index.ts | 1 + playground/package.json | 2 +- pnpm-lock.yaml | 1363 ++- pnpm-workspace.yaml | 1 + 45 files changed, 14669 insertions(+), 62 deletions(-) create mode 100644 agent-demo/.gitignore create mode 100644 agent-demo/README.md create mode 100644 agent-demo/app/api/chat/route.ts create mode 100644 agent-demo/app/assistant.tsx create mode 100644 agent-demo/app/document.tsx create mode 100644 agent-demo/app/favicon.ico create mode 100644 agent-demo/app/globals.css create mode 100644 agent-demo/app/layout.tsx create mode 100644 agent-demo/app/page.tsx create mode 100644 agent-demo/assistant-ui-feedback.md create mode 100644 agent-demo/components.json create mode 100644 agent-demo/components/assistant-ui/assistant-modal.tsx create mode 100644 agent-demo/components/assistant-ui/attachment.tsx create mode 100644 agent-demo/components/assistant-ui/markdown-text.tsx create mode 100644 agent-demo/components/assistant-ui/thread-list.tsx create mode 100644 agent-demo/components/assistant-ui/thread.tsx create mode 100644 agent-demo/components/assistant-ui/threadlist-sidebar.tsx create mode 100644 agent-demo/components/assistant-ui/tool-fallback.tsx create mode 100644 agent-demo/components/assistant-ui/tooltip-icon-button.tsx create mode 100644 agent-demo/components/ui/avatar.tsx create mode 100644 agent-demo/components/ui/breadcrumb.tsx create mode 100644 agent-demo/components/ui/button.tsx create mode 100644 agent-demo/components/ui/dialog.tsx create mode 100644 agent-demo/components/ui/input.tsx create mode 100644 agent-demo/components/ui/separator.tsx create mode 100644 agent-demo/components/ui/sheet.tsx create mode 100644 agent-demo/components/ui/sidebar.tsx create mode 100644 agent-demo/components/ui/skeleton.tsx create mode 100644 agent-demo/components/ui/tooltip.tsx create mode 100644 agent-demo/eslint.config.mjs create mode 100644 agent-demo/hooks/use-mobile.ts create mode 100644 agent-demo/lib/utils.ts create mode 100644 agent-demo/next.config.ts create mode 100644 agent-demo/package-lock.json create mode 100644 agent-demo/package.json create mode 100644 agent-demo/postcss.config.mjs create mode 100644 agent-demo/tsconfig.json diff --git a/agent-demo/.gitignore b/agent-demo/.gitignore new file mode 100644 index 0000000000..5ef6a52078 --- /dev/null +++ b/agent-demo/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/agent-demo/README.md b/agent-demo/README.md new file mode 100644 index 0000000000..5bc3887430 --- /dev/null +++ b/agent-demo/README.md @@ -0,0 +1,25 @@ +This is the [assistant-ui](https://github.com/Yonom/assistant-ui) starter project. + +## Getting Started + +First, add your OpenAI API key to `.env.local` file: + +``` +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Then, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. diff --git a/agent-demo/app/api/chat/route.ts b/agent-demo/app/api/chat/route.ts new file mode 100644 index 0000000000..be258a2264 --- /dev/null +++ b/agent-demo/app/api/chat/route.ts @@ -0,0 +1,24 @@ +import { openai } from "@ai-sdk/openai"; +import { frontendTools } from "@assistant-ui/react-ai-sdk"; +import { convertToModelMessages, streamText, UIMessage } from "ai"; +import { JSONSchema7 } from "json-schema"; + +export async function POST(req: Request) { + const { + messages, + tools, + }: { + messages: UIMessage[]; + tools: Record; + } = await req.json(); + const result = streamText({ + model: openai("gpt-5-nano"), + messages: convertToModelMessages(messages), + tools: { + ...(frontendTools(tools) as any), + web_search: openai.tools.webSearch({}), + }, + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/agent-demo/app/assistant.tsx b/agent-demo/app/assistant.tsx new file mode 100644 index 0000000000..8eec334ad7 --- /dev/null +++ b/agent-demo/app/assistant.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { AssistantModal } from "@/components/assistant-ui/assistant-modal"; +import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Chat, useChat } from "@ai-sdk/react"; +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { + AssistantChatTransport, + useAISDKRuntime, +} from "@assistant-ui/react-ai-sdk"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; + +import { BlockNoteEditor } from "@blocknote/core"; +import { _getApplySuggestionsTr, getAIExtension } from "@blocknote/xl-ai"; +import { isToolOrDynamicToolUIPart, isToolUIPart, UIMessage } from "ai"; + +import dynamic from "next/dynamic"; +import { Node, Slice } from "prosemirror-model"; +const transport = new AssistantChatTransport({ + api: "/api/chat", +}); +const Document = dynamic(() => import("./document"), { ssr: false }); + +export const Assistant = () => { + const chatRef = useRef>(undefined); + if (!chatRef.current) { + // TODO: would be better to get rid of this, but currently we need the raw chat object (on context) + chatRef.current = new Chat({ + // api: "/api/chat", + transport, + }); + chatRef.current.sendMessageOrig = chatRef.current.sendMessage; + chatRef.current.sendMessage = async (message: UIMessage) => { + if (chatRef.current.sendMessageAlt) { + return chatRef.current.sendMessageAlt(message); + } else { + return chatRef.current.sendMessageOrig(message); + } + }; + } + const chat = useChat({ + chat: chatRef.current, + // api: "/api/chat", + // transport, + }); + const runtime = useAISDKRuntime(chat); + + useEffect(() => { + // not documented! + transport.setRuntime(runtime); + }, [runtime]); + // const runtime = useChatRuntime({ + // transport: new AssistantChatTransport({ + // api: "/api/chat", + // }), + // }); + + useEffect(() => { + console.log(chat); + // debugger; + }, [chat]); + + return ( + + + +
+ + +
+ + + + + + {/* */} + Agent demo + {/* */} + + {/* + + Starter Template + */} + + +
+
+ {/* */} +
+ +
+
+
+
+
+ +
+
+ ); +}; + +const ChatContextProvider = ({ + children, + chat, +}: { + children: React.ReactNode; + chat: Chat; +}) => { + // Note: lots of functionality on context, maybe some can be moved to components? + const [editor, setEditor] = useState< + BlockNoteEditor | undefined + >(undefined); + + const [hasSuggestions, setHasSuggestions] = useState(false); + + const acceptChanges = () => { + if (!editor) { + throw new Error("Editor not found"); + } + getAIExtension(editor).acceptChanges(); + chat.messages = chat.messages.map((message) => { + if ( + message.role === "assistant" && + message.parts.some( + (part) => + isToolUIPart(part) && part.type === "tool-applyDocumentOperations", + ) + ) { + if (message.metadata?.applied === undefined) { + message.metadata = { ...(message.metadata || {}), applied: true }; + } + } + return message; + }); + }; + const rejectChanges = () => { + if (!editor) { + throw new Error("Editor not found"); + } + getAIExtension(editor).rejectChanges(); + chat.messages = chat.messages.map((message) => { + if ( + message.role === "assistant" && + message.parts.some( + (part) => + isToolUIPart(part) && part.type === "tool-applyDocumentOperations", + ) + ) { + if ((message.metadata as any).applied === undefined) { + message.metadata = { ...(message.metadata || {}), applied: false }; + } + } + return message; + }); + }; + + const [previewDocument, setPreviewDocument] = useState(); + + useEffect(() => { + if (!editor) { + return; + } + const unsubscribe = editor.onChange((editor) => { + const tr = _getApplySuggestionsTr(editor); + if (tr.docChanged) { + setHasSuggestions(true); + } else { + setHasSuggestions(false); + } + }); + return unsubscribe; + }, [editor]); + + const previousDocumentState = useRef(undefined); + + useEffect(() => { + console.log("previewDocument", previewDocument); + if (!editor) { + return; + } + if (!previewDocument) { + console.log("resetting document 1"); + if (previousDocumentState.current) { + // reset + console.log("resetting document"); + + editor.transact((tr) => { + tr.replace( + 0, + tr.doc.content.size, + new Slice(previousDocumentState.current!.content, 0, 0), + ); + }); + previousDocumentState.current = undefined; + } + return; + } + + // set preview document + const documentState = getMessageWithToolCallId(chat, previewDocument) + ?.metadata?.documentState; + + const newNode = Node.fromJSON(editor.pmSchema, documentState.pm); + + if (editor.prosemirrorState.doc.eq(newNode)) { + // duplicate strict mode render? + // smelly, probably wrong architecture between effect / ref / etc. + return; + } + + console.log("documentState", documentState); + previousDocumentState.current = editor._tiptapEditor.state.doc; + editor.transact((tr) => { + tr.replace(0, tr.doc.content.size, new Slice(newNode.content, 0, 0)); + }); + }, [chat, editor, previewDocument, previousDocumentState]); + + return ( + + {children} + + ); +}; + +export function getMessageWithToolCallId( + chat: Chat, + toolCallId: string, +) { + return chat.messages.find( + (m) => + m.role === "assistant" && + m.parts.some( + (p) => isToolOrDynamicToolUIPart(p) && p.toolCallId === toolCallId, + ), + ); +} +// function getDocumentStateBeforeToolCall( +// chat: Chat, +// toolCallId: string, +// ) { +// let index = chat.messages.findIndex( +// (m) => +// m.role === "assistant" && +// m.parts.some( +// (p) => isToolOrDynamicToolUIPart(p) && p.toolCallId === toolCallId, +// ), +// ); +// if (index === -1) { +// throw new Error("Tool call not found"); +// } +// return chat.messages[index].metadata?.documentState; +// // while (index >= 0) { +// // const message = chat.messages[index]; +// // // find the last document-state message before the tool call and get the metadata +// // if ( +// // message.role === "assistant" && +// // message.id.startsWith("document-state-") +// // ) { +// // const state = (message.metadata as any)?.documentState; +// // return state; +// // } + +// // index--; +// // } +// throw new Error("Document state not found"); +// } + +type ChatContextType = { + chat: Chat; + hasSuggestions: boolean; + setEditor: (editor: BlockNoteEditor) => void; + acceptChanges: () => void; + rejectChanges: () => void; + setPreviewDocument: (toolCallId: string | undefined) => void; +}; + +const ChatContext = createContext(undefined); + +export const useChatContext = () => { + const chat = useContext(ChatContext); + if (!chat) { + throw new Error("ChatContext not found"); + } + return chat; +}; diff --git a/agent-demo/app/document.tsx b/agent-demo/app/document.tsx new file mode 100644 index 0000000000..d6ef38c27a --- /dev/null +++ b/agent-demo/app/document.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { useChat } from "@ai-sdk/react"; +import { + ActionBarPrimitive, + makeAssistantTool, + makeAssistantToolUI, + tool, + ToolCallMessagePartComponent, + ToolCallMessagePartProps, + useAssistantApi, +} from "@assistant-ui/react"; +import { BlockNoteEditor, filterSuggestionItems } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { en } from "@blocknote/core/locales"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + FormattingToolbar, + FormattingToolbarController, + getDefaultReactSlashMenuItems, + getFormattingToolbarItems, + SuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; +import { + aiDocumentFormats, + AIMenuController, + AIToolbarButton, + createAIAutoCompleteExtension, + createAIExtension, + createStreamToolsArraySchema, + getAISlashMenuItems, + setupToolCallStreaming, +} from "@blocknote/xl-ai"; +import { en as aiEn } from "@blocknote/xl-ai/locales"; +import "@blocknote/xl-ai/style.css"; +import { DefaultChatTransport, isToolOrDynamicToolUIPart, UIMessage } from "ai"; +import { PencilIcon, UndoIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo } from "react"; +import { getMessageWithToolCallId, useChatContext } from "./assistant"; +const BASE_URL = "https://localhost:3000/ai"; + +const UndoActionBar: FC = () => { + return ( + + {/* */} + + + + {/* */} + + ); +}; + +const BlockNoteToolUI: ToolCallMessagePartComponent = ( + props: ToolCallMessagePartProps & { + editor: BlockNoteEditor; + }, +) => { + const ctx = useChatContext(); + console.log("render"); + const onHover = useCallback(() => { + ctx.setPreviewDocument(props.toolCallId); + }, [props.toolCallId]); + + const onMouseLeave = useCallback(() => { + ctx.setPreviewDocument(undefined); + console.log("onMouseLeave"); + }, [ctx]); + + const message = getMessageWithToolCallId(ctx.chat, props.toolCallId); + + if (props.status.type === "running") { + return ( +
+ Updating document... +
+ ); + } else if (props.status.type === "complete") { + debugger; + return ( + // TODO: should get rid of min-h-10, but otherwise layout shifts when undo is shown +
+ {" "} + + Updated document + + {/* */} + +
+ ); + } else { + throw new Error("Not implemented"); + } +}; + +export default function Document() { + const api = useAssistantApi(); + + const ctx = useChatContext(); + const chatRaw = ctx.chat; + const chat = useChat({ + chat: chatRaw, + }); + + // TODO: prefer to migrate to this? + // useEffect(() => { + // api.on({ event: "*", scope: "*" }, (event) => { + // console.log("composer.sendMessages", event); + // debugger; + // // const message = api.thread().getState().messages[0]; + // // message. + // }); + // }, [api]); + + // Creates a new editor instance. + const editor = useCreateBlockNote({ + dictionary: { + ...en, + ai: aiEn, // add default translations for the AI extension + }, + // Register the AI extension + extensions: [ + createAIExtension({ + transport: new DefaultChatTransport({ + // URL to your backend API, see example source in `packages/xl-ai-server/src/routes/regular.ts` + api: `${BASE_URL}/regular/streamText`, + }), + }), + createAIAutoCompleteExtension(), + ], + // We set some initial content for demo purposes + initialContent: [ + { + type: "heading", + props: { + level: 1, + }, + content: "Open source software", + }, + { + type: "paragraph", + content: + "Open source software refers to computer programs whose source code is made available to the public, allowing anyone to view, modify, and distribute the code. This model stands in contrast to proprietary software, where the source code is kept secret and only the original creators have the right to make changes. Open projects are developed collaboratively, often by communities of developers from around the world, and are typically distributed under licenses that promote sharing and openness.", + }, + { + type: "paragraph", + content: + "One of the primary benefits of open source is the promotion of digital autonomy. By providing access to the source code, these programs empower users to control their own technology, customize software to fit their needs, and avoid vendor lock-in. This level of transparency also allows for greater security, as anyone can inspect the code for vulnerabilities or malicious elements. As a result, users are not solely dependent on a single company for updates, bug fixes, or continued support.", + }, + { + type: "paragraph", + content: + "Additionally, open development fosters innovation and collaboration. Developers can build upon existing projects, share improvements, and learn from each other, accelerating the pace of technological advancement. The open nature of these projects often leads to higher quality software, as bugs are identified and fixed more quickly by a diverse group of contributors. Furthermore, using open source can reduce costs for individuals, businesses, and governments, as it is often available for free and can be tailored to specific requirements without expensive licensing fees.", + }, + ], + }); + + useEffect(() => { + ctx.setEditor(editor); + }, [ctx, editor]); + + const streamTools = useMemo(() => { + return aiDocumentFormats.html + .getStreamToolsProvider() + .getStreamTools(editor); // TODO: not document dependent? + }, [editor]); + + useEffect(() => { + // a somewhat hacky way to insert document state before the user submits a new message to the llm + // this way, the llm always has the latest document state to work with + + // hacky, other options: + // - use assistant-ui api? + // - inject in transport? + // - inject on backend? + + (chatRaw as any).sendMessageAlt = async (message: UIMessage) => { + if (!message) { + throw new Error("not implemented"); + } + if (message.role === "user") { + const beforeLastMessage = chatRaw.messages[chatRaw.messages.length - 1]; + + if (beforeLastMessage?.id.startsWith("document-state-")) { + // already inserted + return; + } + + // TODO: this async might break if it's actually async + const f = async () => { + const doc = + await aiDocumentFormats.html.promptHelpers.getDataForPromptNoSelection( + editor, + {}, + ); + + chatRaw.messages = [ + ...chatRaw.messages, + + { + id: "document-state-" + chatRaw.messages.length, + role: "assistant", + parts: [ + { + toolCallId: "document-state-tc-" + chatRaw.messages.length, + type: "dynamic-tool", + toolName: "document-state", + state: "output-available", + input: {}, + output: JSON.stringify(doc.htmlBlocks), + // text: "The latest state of the document is: \n\n", + // }, + // { + // type: "text", + // text: JSON.stringify(doc.htmlBlocks), + }, + ], + metadata: { + documentState: { + pm: editor.prosemirrorState.doc.toJSON(), + bn: editor.document, + }, + }, + }, + ]; + + const p = setupToolCallStreaming(streamTools, chatRaw, () => { + // debugger; + }); + + p.then(() => { + const lastMessage = chatRaw.messages[chatRaw.messages.length - 1]; + if ( + lastMessage.role === "assistant" && + lastMessage.parts.some( + (p) => + isToolOrDynamicToolUIPart(p) && + p.type === "tool-applyDocumentOperations", + ) + ) { + lastMessage.metadata = { + documentState: { + pm: editor.prosemirrorState.doc.toJSON(), + bn: editor.document, + }, + }; + } + }); + (await (chatRaw as any).sendMessageOrig(message)) as any; + }; + f(); + } + }; + }, [chatRaw, editor]); + + const BlockNoteTool = makeAssistantTool({ + // type: "frontend", + toolName: "applyDocumentOperations", + ...tool({ + parameters: createStreamToolsArraySchema(streamTools), + execute: async (args) => { + // debugger; + console.log(args); + }, + }), + render: (props) => , + }); + + const DocumentStateTool = makeAssistantToolUI({ + toolName: "document-state", + render: (props) => null, + }); + // Renders the editor instance using a React component. + return ( +
+ + + + {/* Add the AI Command menu to the editor */} + + + {/* We disabled the default formatting toolbar with `formattingToolbar=false` + and replace it for one with an "AI button" (defined below). + (See "Formatting Toolbar" in docs) + */} + + + {/* We disabled the default SlashMenu with `slashMenu=false` + and replace it for one with an AI option (defined below). + (See "Suggestion Menus" in docs) + */} + + +
+ ); +} + +// Formatting toolbar with the `AIToolbarButton` added +function FormattingToolbarWithAI() { + return ( + ( + + {...getFormattingToolbarItems()} + {/* Add the AI button */} + + + )} + /> + ); +} + +// Slash menu with the AI option added +function SuggestionMenuWithAI(props: { + editor: BlockNoteEditor; +}) { + return ( + + filterSuggestionItems( + [ + ...getDefaultReactSlashMenuItems(props.editor), + // add the default AI slash menu items, or define your own + ...getAISlashMenuItems(props.editor), + ], + query, + ) + } + /> + ); +} diff --git a/agent-demo/app/favicon.ico b/agent-demo/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/agent-demo/app/globals.css b/agent-demo/app/globals.css new file mode 100644 index 0000000000..a6986b058a --- /dev/null +++ b/agent-demo/app/globals.css @@ -0,0 +1,156 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --animate-shimmer: shimmer-sweep var(--shimmer-duration, 1000ms) linear infinite both; + @keyframes shimmer-sweep { + from { + background-position: 150% 0; + } + to { + background-position: -100% 0; + } + } + @keyframes shimmer-sweep { + from { + background-position: 150% 0; + } + to { + background-position: -100% 0; + } + } + @keyframes shimmer-sweep { + from { + background-position: 150% 0; + } + to { + background-position: -100% 0; + } + } +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + :root { + color-scheme: light; + } + + :root.dark { + color-scheme: dark; + } + + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/agent-demo/app/layout.tsx b/agent-demo/app/layout.tsx new file mode 100644 index 0000000000..3e747110b2 --- /dev/null +++ b/agent-demo/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "assistant-ui Starter App", + description: "Generated by create-assistant-ui", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/agent-demo/app/page.tsx b/agent-demo/app/page.tsx new file mode 100644 index 0000000000..c222980140 --- /dev/null +++ b/agent-demo/app/page.tsx @@ -0,0 +1,5 @@ +import { Assistant } from "./assistant"; + +export default function Home() { + return ; +} diff --git a/agent-demo/assistant-ui-feedback.md b/agent-demo/assistant-ui-feedback.md new file mode 100644 index 0000000000..7de07ec5b9 --- /dev/null +++ b/agent-demo/assistant-ui-feedback.md @@ -0,0 +1,3 @@ +- need to call `transport.setRuntime` with manual ai-sdk setup is not document +- npx create creates an API handler that doesn't read tools (route.ts) +- some components don't have examples / screenshots (reasoning) diff --git a/agent-demo/components.json b/agent-demo/components.json new file mode 100644 index 0000000000..a64445d7a5 --- /dev/null +++ b/agent-demo/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/agent-demo/components/assistant-ui/assistant-modal.tsx b/agent-demo/components/assistant-ui/assistant-modal.tsx new file mode 100644 index 0000000000..08f2a22769 --- /dev/null +++ b/agent-demo/components/assistant-ui/assistant-modal.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { BotIcon, ChevronDownIcon } from "lucide-react"; + +import { AssistantModalPrimitive } from "@assistant-ui/react"; +import { type FC, forwardRef } from "react"; + +import { Thread } from "@/components/assistant-ui/thread"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; + +export const AssistantModal: FC = () => { + return ( + + + + + + + + + + + ); +}; + +type AssistantModalButtonProps = { "data-state"?: "open" | "closed" }; + +const AssistantModalButton = forwardRef< + HTMLButtonElement, + AssistantModalButtonProps +>(({ "data-state": state, ...rest }, ref) => { + const tooltip = state === "open" ? "Close Assistant" : "Open Assistant"; + + return ( + + + + + {tooltip} + + ); +}); + +AssistantModalButton.displayName = "AssistantModalButton"; diff --git a/agent-demo/components/assistant-ui/attachment.tsx b/agent-demo/components/assistant-ui/attachment.tsx new file mode 100644 index 0000000000..4f513e43ff --- /dev/null +++ b/agent-demo/components/assistant-ui/attachment.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { PropsWithChildren, useEffect, useState, type FC } from "react"; +import Image from "next/image"; +import { XIcon, PlusIcon, FileText } from "lucide-react"; +import { + AttachmentPrimitive, + ComposerPrimitive, + MessagePrimitive, + useAssistantState, + useAssistantApi, +} from "@assistant-ui/react"; +import { useShallow } from "zustand/shallow"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +const useFileSrc = (file: File | undefined) => { + const [src, setSrc] = useState(undefined); + + useEffect(() => { + if (!file) { + setSrc(undefined); + return; + } + + const objectUrl = URL.createObjectURL(file); + setSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [file]); + + return src; +}; + +const useAttachmentSrc = () => { + const { file, src } = useAssistantState( + useShallow(({ attachment }): { file?: File; src?: string } => { + if (attachment.type !== "image") return {}; + if (attachment.file) return { file: attachment.file }; + const src = attachment.content?.filter((c) => c.type === "image")[0] + ?.image; + if (!src) return {}; + return { src }; + }), + ); + + return useFileSrc(file) ?? src; +}; + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + return ( + Image Preview setIsLoaded(true)} + priority={false} + /> + ); +}; + +const AttachmentPreviewDialog: FC = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) return children; + + return ( + + + {children} + + + + Image Attachment Preview + +
+ +
+
+
+ ); +}; + +const AttachmentThumb: FC = () => { + const isImage = useAssistantState( + ({ attachment }) => attachment.type === "image", + ); + const src = useAttachmentSrc(); + + return ( + + + + + + + ); +}; + +const AttachmentUI: FC = () => { + const api = useAssistantApi(); + const isComposer = api.attachment.source === "composer"; + + const isImage = useAssistantState( + ({ attachment }) => attachment.type === "image", + ); + const typeLabel = useAssistantState(({ attachment }) => { + const type = attachment.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + const _exhaustiveCheck: never = type; + throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`); + } + }); + + return ( + + #attachment-tile]:size-24", + )} + > + + +
+ +
+
+
+ {isComposer && } +
+ + + +
+ ); +}; + +const AttachmentRemove: FC = () => { + return ( + + + + + + ); +}; + +export const UserMessageAttachments: FC = () => { + return ( +
+ +
+ ); +}; + +export const ComposerAttachments: FC = () => { + return ( +
+ +
+ ); +}; + +export const ComposerAddAttachment: FC = () => { + return ( + + + + + + ); +}; diff --git a/agent-demo/components/assistant-ui/markdown-text.tsx b/agent-demo/components/assistant-ui/markdown-text.tsx new file mode 100644 index 0000000000..5f3ee56982 --- /dev/null +++ b/agent-demo/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,228 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import remarkGfm from "remark-gfm"; +import { type FC, memo, useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; + +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +const MarkdownTextImpl = () => { + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ + {language} + + + {!isCopied && } + {isCopied && } + +
+ ); +}; + +const useCopyToClipboard = ({ + copiedDuration = 3000, +}: { + copiedDuration?: number; +} = {}) => { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = (value: string) => { + if (!value) return; + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), copiedDuration); + }); + }; + + return { isCopied, copyToClipboard }; +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + h5: ({ className, ...props }) => ( +

+ ), + h6: ({ className, ...props }) => ( +
+ ), + p: ({ className, ...props }) => ( +

+ ), + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-2", className)} + {...props} + /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-2", className)} + {...props} + /> + ), + hr: ({ className, ...props }) => ( +
      + ), + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg", + className, + )} + {...props} + /> + ), + sup: ({ className, ...props }) => ( + a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }) => ( +
      +  ),
      +  code: function Code({ className, ...props }) {
      +    const isCodeBlock = useIsMarkdownCodeBlock();
      +    return (
      +      
      +    );
      +  },
      +  CodeHeader,
      +});
      diff --git a/agent-demo/components/assistant-ui/thread-list.tsx b/agent-demo/components/assistant-ui/thread-list.tsx
      new file mode 100644
      index 0000000000..76045476bb
      --- /dev/null
      +++ b/agent-demo/components/assistant-ui/thread-list.tsx
      @@ -0,0 +1,95 @@
      +import type { FC } from "react";
      +import {
      +  ThreadListItemPrimitive,
      +  ThreadListPrimitive,
      +  useAssistantState,
      +} from "@assistant-ui/react";
      +import { ArchiveIcon, PlusIcon } from "lucide-react";
      +
      +import { Button } from "@/components/ui/button";
      +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
      +import { Skeleton } from "@/components/ui/skeleton";
      +
      +export const ThreadList: FC = () => {
      +  return (
      +    
      +      
      +      
      +    
      +  );
      +};
      +
      +const ThreadListNew: FC = () => {
      +  return (
      +    
      +      
      +    
      +  );
      +};
      +
      +const ThreadListItems: FC = () => {
      +  const isLoading = useAssistantState(({ threads }) => threads.isLoading);
      +
      +  if (isLoading) {
      +    return ;
      +  }
      +
      +  return ;
      +};
      +
      +const ThreadListSkeleton: FC = () => {
      +  return (
      +    <>
      +      {Array.from({ length: 5 }, (_, i) => (
      +        
      + +
      + ))} + + ); +}; + +const ThreadListItem: FC = () => { + return ( + + + + + + + ); +}; + +const ThreadListItemTitle: FC = () => { + return ( + + + + ); +}; + +const ThreadListItemArchive: FC = () => { + return ( + + + + + + ); +}; diff --git a/agent-demo/components/assistant-ui/thread.tsx b/agent-demo/components/assistant-ui/thread.tsx new file mode 100644 index 0000000000..400f893d60 --- /dev/null +++ b/agent-demo/components/assistant-ui/thread.tsx @@ -0,0 +1,447 @@ +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CopyIcon, + PencilIcon, + Square, +} from "lucide-react"; + +import { + ActionBarPrimitive, + BranchPickerPrimitive, + ComposerPrimitive, + ErrorPrimitive, + MessagePrimitive, + ThreadPrimitive, + useAssistantState, +} from "@assistant-ui/react"; + +import { LazyMotion, MotionConfig, domAnimation } from "motion/react"; +import * as m from "motion/react-m"; +import type { FC } from "react"; + +import { + ComposerAddAttachment, + ComposerAttachments, + UserMessageAttachments, +} from "@/components/assistant-ui/attachment"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Button } from "@/components/ui/button"; + +import { useChatContext } from "@/app/assistant"; +import { cn } from "@/lib/utils"; + +export const Thread: FC = () => { + return ( + + + + + + + + + + + +
      + + + + + + + + ); +}; + +const ThreadScrollToBottom: FC = () => { + return ( + + + + + + ); +}; + +const ThreadWelcome: FC = () => { + return ( +
      +
      +
      + + Hello there! + + + How can I help you today? + +
      +
      + +
      + ); +}; + +const ThreadSuggestions: FC = () => { + return ( +
      + {[ + { + title: "Summarize", + label: "the document", + action: "Create a short summary of the document", + }, + { + title: "Add action items", + label: "to the document", + action: "Add action items to the document", + }, + ].map((suggestedAction, index) => ( + + + + + + ))} +
      + ); +}; + +// Added +const ThreadAcceptReject: FC = () => { + const ctx = useChatContext(); + if (!ctx.hasSuggestions) { + return null; + } + return ( +
      + {[ + { + title: "Accept", + label: "suggested changes", + action: () => { + ctx.acceptChanges(); + }, + }, + { + title: "Reject", + label: "revert the document", + action: () => { + ctx.rejectChanges(); + }, + }, + ].map((suggestedAction, index) => ( + + {/* */} + + {/* */} + + ))} +
      + ); +}; + +const Composer: FC = () => { + return ( +
      + + + + + + +
      + ); +}; + +const ComposerAction: FC = () => { + return ( +
      + + + + + + + + + + + + + + + +
      + ); +}; + +const MessageError: FC = () => { + return ( + + + + + + ); +}; + +const AssistantMessage: FC = () => { + // added to hide specific messages + const isDocumentStateMessage = useAssistantState( + ({ message }) => + message.parts.length > 0 && + message.parts[0].type === "tool-call" && + message.parts[0].toolName === "document-state", + ); + + if (isDocumentStateMessage) { + return null; + } + + return ( + +
      +
      + + +
      + + {/*
      + + +
      */} +
      +
      + ); +}; + +const AssistantActionBar: FC = () => { + return ( + + + + + + + + + + + + {/* + + + + */} + + ); +}; + +const UserMessage: FC = () => { + return ( + +
      + + +
      +
      + +
      + {/*
      + +
      */} +
      + + +
      +
      + ); +}; + +const UserActionBar: FC = () => { + return ( + + + + + + + + ); +}; + +const EditComposer: FC = () => { + return ( +
      + + + +
      + + + + + + +
      +
      +
      + ); +}; + +const BranchPicker: FC = ({ + className, + ...rest +}) => { + return ( + + + + + + + + / + + + + + + + + ); +}; diff --git a/agent-demo/components/assistant-ui/threadlist-sidebar.tsx b/agent-demo/components/assistant-ui/threadlist-sidebar.tsx new file mode 100644 index 0000000000..fa16d11989 --- /dev/null +++ b/agent-demo/components/assistant-ui/threadlist-sidebar.tsx @@ -0,0 +1,72 @@ +import { ThreadList } from "@/components/assistant-ui/thread-list"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; +import { MessagesSquare } from "lucide-react"; +import Link from "next/link"; +import * as React from "react"; + +export function ThreadListSidebar({ + ...props +}: React.ComponentProps) { + return ( + + +
      + + + + +
      + +
      +
      + + BlockNote AI + +
      + +
      +
      +
      +
      +
      + + + + + {/* + + + + +
      + +
      +
      + + GitHub + + View Source +
      + +
      +
      +
      +
      */} +
      + ); +} diff --git a/agent-demo/components/assistant-ui/tool-fallback.tsx b/agent-demo/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 0000000000..aca40305b5 --- /dev/null +++ b/agent-demo/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,46 @@ +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export const ToolFallback: ToolCallMessagePartComponent = ({ + toolName, + argsText, + result, +}) => { + const [isCollapsed, setIsCollapsed] = useState(true); + return ( +
      +
      + +

      + Used tool: {toolName} +

      + +
      + {!isCollapsed && ( +
      +
      +
      +              {argsText}
      +            
      +
      + {result !== undefined && ( +
      +

      + Result: +

      +
      +                {typeof result === "string"
      +                  ? result
      +                  : JSON.stringify(result, null, 2)}
      +              
      +
      + )} +
      + )} +
      + ); +}; diff --git a/agent-demo/components/assistant-ui/tooltip-icon-button.tsx b/agent-demo/components/assistant-ui/tooltip-icon-button.tsx new file mode 100644 index 0000000000..54b5fa27a7 --- /dev/null +++ b/agent-demo/components/assistant-ui/tooltip-icon-button.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { ComponentPropsWithRef, forwardRef } from "react"; +import { Slottable } from "@radix-ui/react-slot"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type TooltipIconButtonProps = ComponentPropsWithRef & { + tooltip: string; + side?: "top" | "bottom" | "left" | "right"; +}; + +export const TooltipIconButton = forwardRef< + HTMLButtonElement, + TooltipIconButtonProps +>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { + return ( + + + + + {tooltip} + + ); +}); + +TooltipIconButton.displayName = "TooltipIconButton"; diff --git a/agent-demo/components/ui/avatar.tsx b/agent-demo/components/ui/avatar.tsx new file mode 100644 index 0000000000..f7923d49a9 --- /dev/null +++ b/agent-demo/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/agent-demo/components/ui/breadcrumb.tsx b/agent-demo/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000..a0df2b3669 --- /dev/null +++ b/agent-demo/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return
      + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +