Compare commits

...

48 Commits

Author SHA1 Message Date
Danny Avila
30aaf4e0d4 wip: mermaid 2024-09-02 00:19:18 -04:00
Danny Avila
5aee9db6de feat: open/close artifacts 2024-09-01 23:55:55 -04:00
Danny Avila
6a8d4e43db feat: refresh button 2024-09-01 23:55:29 -04:00
Danny Avila
e39a3bafec feat: auto switch to code, adjust prompt, remove unused code 2024-09-01 23:55:07 -04:00
Danny Avila
b1eb069931 feat: autoswitch to preview 2024-09-01 23:54:37 -04:00
Danny Avila
7b20679463 feat: Add auto scrolling to CodeMarkdown (artifacts) 2024-09-01 23:54:13 -04:00
Danny Avila
efbed07569 chore: preprocess artifacts for now 2024-09-01 23:53:46 -04:00
Danny Avila
37002edbba chore: remove thinking for now 2024-09-01 23:53:23 -04:00
Danny Avila
ee126a2350 style: temporarily remove scrolling, add better styling 2024-09-01 23:53:01 -04:00
Danny Avila
e026fc7009 fix: side panel resizing crashes 2024-09-01 23:52:33 -04:00
Danny Avila
8f33fd5cc1 refactor: resizable panel order props 2024-09-01 23:51:09 -04:00
Danny Avila
b16079915d chore: bump react-resizable-panel 2024-09-01 23:49:28 -04:00
Danny Avila
336c7ad21a refactor: abstract out components 2024-09-01 23:48:55 -04:00
Danny Avila
0dd0354a4e feat: memoize ArtifactPreview, add html support 2024-09-01 23:48:29 -04:00
Danny Avila
10f436521e style: remove ArtifactButton inline-block styling 2024-09-01 23:47:34 -04:00
Danny Avila
ac352c9878 refactor: update UI artifact toggle label to match localization key 2024-09-01 23:47:05 -04:00
Danny Avila
e3bcfc560d feat: artifacts system prompt in backend 2024-09-01 23:46:32 -04:00
Danny Avila
fa0032d91d feat: reset artifact state on conversation change 2024-09-01 23:46:09 -04:00
Danny Avila
8c1db607e5 refactor: nullify artifact state and reset on empty conversation 2024-09-01 23:45:38 -04:00
Danny Avila
bedc91adcd refactor: remove artifactIdsState 2024-09-01 23:45:04 -04:00
Danny Avila
2ac9fd3aed refactor: apply growth to artifact frame so artifact preview can take full space 2024-09-01 23:44:36 -04:00
Danny Avila
dc5b597a64 refactor: Rename CodePreview component to ArtifactButton 2024-09-01 23:44:02 -04:00
Danny Avila
48ddf4039e feat: global state for current artifact Id and set on artifact preview click 2024-09-01 23:43:24 -04:00
Danny Avila
f2a516db02 refactor: optimize Markdown rendering and add support for code artifacts 2024-09-01 23:42:52 -04:00
Danny Avila
f1fb8e991c wip: remove thinking text and artifact formatting if empty 2024-09-01 23:42:14 -04:00
Danny Avila
15068fdfab refactor: optimize ordering of artifacts and activate latest artifact in Artifacts component 2024-09-01 23:41:42 -04:00
Danny Avila
4c810fa5db feat: update to latest, use update time for ordering 2024-09-01 23:40:56 -04:00
Danny Avila
8ce70a41b7 refactor: add order prop to Artifact 2024-09-01 23:40:27 -04:00
Danny Avila
068ec2fceb refactor: optimize ordering of artifacts and add latest artifact activation in Artifacts component 2024-09-01 23:40:01 -04:00
Danny Avila
e2e42db24a refactor: optimize ordering of artifacts in Artifacts component 2024-09-01 23:39:10 -04:00
Danny Avila
5be1ffe490 feat: first pass streaming updates 2024-09-01 23:38:38 -04:00
Danny Avila
85711d8ada feat: Add Copy Code button to Artifacts component 2024-09-01 23:38:00 -04:00
Danny Avila
5bf61c3cdf feat: artifacts + shadcnui PROBLEMATIC COMMIT 2024-09-01 23:37:33 -04:00
Danny Avila
9abf941085 refactor: typing and make latest artifact active if the number of artifacts changed 2024-09-01 22:35:09 -04:00
Danny Avila
8e1807d02b fix: only set artifact if there is content 2024-09-01 22:34:29 -04:00
Danny Avila
66d5a1a368 feat: Add useEffect hook to Artifacts component for visibility control, slide in animation 2024-09-01 22:33:44 -04:00
Danny Avila
c8d9af42e5 fix: package markdown fixes 2024-09-01 22:03:34 -04:00
Danny Avila
b3e3788261 style: code styling 2024-09-01 22:03:02 -04:00
Danny Avila
0a54489842 feat: second pass 2024-09-01 22:02:31 -04:00
Danny Avila
6ee70acdc5 style: Add Radix Tabs, more styling changes 2024-09-01 22:01:54 -04:00
Danny Avila
cd749a2bcb first pass styling 2024-09-01 22:01:25 -04:00
Danny Avila
d86d4f5c17 refactor: add throttling 2024-09-01 22:00:56 -04:00
Danny Avila
fc6eb9f77f refactor: first pass rewrite 2024-09-01 22:00:23 -04:00
Danny Avila
85df66265d chore: revert markdown to original component + new artifacts rendering 2024-09-01 21:59:33 -04:00
Danny Avila
801b0de49b WIP first pass remark-directive 2024-09-01 21:52:49 -04:00
Danny Avila
24d74044e4 WIP: artifacts first pass 2024-09-01 21:52:06 -04:00
Danny Avila
0340b4acb9 chore: Update npm dependency to @codesandbox/sandpack-react@2.18.2 2024-09-01 21:50:23 -04:00
Danny Avila
8c162842d6 feat: Add CodeArtifacts component to Beta settings tab 2024-09-01 21:49:48 -04:00
51 changed files with 5699 additions and 473 deletions

View File

@@ -0,0 +1,162 @@
const artifactsPrompt = `The assistant can create and reference artifacts during conversations.
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
# Good artifacts are...
- Substantial content (>15 lines)
- Content that the user is likely to modify, iterate on, or take ownership of
- Self-contained, complex content that can be understood on its own, without context from the conversation
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
- Content likely to be referenced or reused multiple times
# Don't use artifacts for...
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
- Suggestions, commentary, or feedback on existing artifacts
- Conversational or explanatory content that doesn't represent a standalone piece of work
- Content that is dependent on the current conversational context to be useful
- Content that is unlikely to be modified or iterated upon by the user
- Request from users that appears to be a one-off question
# Usage notes
- One artifact per message unless specifically requested
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
- Always provide complete, specific, and fully functional content without any placeholders, ellipses, or 'remains the same' comments.
<artifact_instructions>
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
1. Create the artifact using the following format:
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
\`\`\`
Your artifact content here
\`\`\`
:::
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
3. Include a \`title\` attribute to provide a brief title or description of the content.
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- Code: "application/vnd.code"
- Use for code snippets or scripts in any programming language.
- Include the language name as the value of the \`language\` attribute (e.g., \`language="python"\`).
- Documents: "text/markdown"
- Plain text, Markdown, or other formatted text documents
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- It is inappropriate to use "text/html" when sharing snippets, code samples & example HTML or CSS code, as it would be rendered as a webpage and the source code would be obscured. The assistant should instead use "application/vnd.code" defined above.
- If the assistant is unable to follow the above requirements for any reason, use "application/vnd.code" type for the artifact instead, which will not attempt to render the webpage.
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
- The lucide-react@0.263.1 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- If you are unable to follow the above requirements for any reason, use "application/vnd.code" type for the artifact instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
</artifact_instructions>
Here are some examples of correct usage of artifacts:
<examples>
<example_docstring>
This example demonstrates how to create a new artifact for a Python script.
</example_docstring>
<example>
<user_query>Can you help me create a Python script to calculate the factorial of a number?</user_query>
<assistant_response>
Sure! Here's a Python script that calculates the factorial of a number:
:::artifact{identifier="factorial-script" type="application/vnd.code" language="python" title="Simple Python factorial script"}
\`\`\`python
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n - 1)
# Get user input
num = int(input("Enter a non-negative integer: "))
# Calculate and print the factorial
result = factorial(num)
print(f"The factorial of {num} is {result}")
\`\`\`
:::
This script defines a \`factorial\` function using recursion to calculate the factorial of a given number. It then prompts the user for input, calculates the factorial, and prints the result.
To use this script:
1. Copy the code into a .py file
2. Run the script
3. Enter a non-negative integer when prompted
4. The script will output the factorial of the number you entered
Let me know if you have any questions or if you'd like to modify the script in any way!
</assistant_response>
</example>
<example_docstring>
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
</example_docstring>
<example>
<user_query>Can you create a simple flow chart showing the process of making tea using Mermaid?</user_query>
<assistant_response>
Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
\`\`\`mermaid
graph TD
A[Start] --> B{Water boiled?}
B -->|Yes| C[Add tea leaves to cup]
B -->|No| D[Boil water]
D --> B
C --> E[Pour boiling water into cup]
E --> F[Steep tea for desired time]
F --> G[Remove tea leaves]
G --> H[Add milk or sugar, if desired]
H --> I[Enjoy your tea!]
I --> J[End]
\`\`\`
:::
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
1. Start
2. Check if water is boiled
3. If not boiled, boil the water
4. Once water is boiled, add tea leaves to the cup
5. Pour boiling water into the cup
6. Steep the tea for the desired time
7. Remove the tea leaves
8. Optionally add milk or sugar
9. Enjoy your tea!
10. End
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
</assistant_response>
</example>
</examples>`;
module.exports = artifactsPrompt;

View File

@@ -1,4 +1,5 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
@@ -10,6 +11,7 @@ const buildOptions = (endpoint, parsedBody) => {
iconURL,
greeting,
spec,
artifacts,
...modelOptions
} = parsedBody;
@@ -26,6 +28,10 @@ const buildOptions = (endpoint, parsedBody) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

View File

@@ -1,8 +1,10 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
// eslint-disable-next-line no-unused-vars
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...modelOptions } = parsedBody;
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
parsedBody;
const endpointOption = removeNullishValues({
endpoint,
promptPrefix,
@@ -13,6 +15,10 @@ const buildOptions = (endpoint, parsedBody) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

View File

@@ -1,8 +1,10 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
// eslint-disable-next-line no-unused-vars
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...modelOptions } = parsedBody;
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
parsedBody;
const endpointOption = removeNullishValues({
endpoint,
promptPrefix,
@@ -13,6 +15,10 @@ const buildOptions = (endpoint, parsedBody) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

View File

@@ -1,4 +1,5 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody, endpointType) => {
const {
@@ -10,6 +11,7 @@ const buildOptions = (endpoint, parsedBody, endpointType) => {
iconURL,
greeting,
spec,
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = removeNullishValues({
@@ -26,6 +28,10 @@ const buildOptions = (endpoint, parsedBody, endpointType) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

View File

@@ -1,4 +1,5 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
@@ -9,6 +10,7 @@ const buildOptions = (endpoint, parsedBody) => {
iconURL,
greeting,
spec,
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = removeNullishValues({
@@ -23,6 +25,10 @@ const buildOptions = (endpoint, parsedBody) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

View File

@@ -1,4 +1,5 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
@@ -10,6 +11,7 @@ const buildOptions = (endpoint, parsedBody) => {
greeting,
spec,
maxContextTokens,
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = removeNullishValues({
@@ -28,6 +30,10 @@ const buildOptions = (endpoint, parsedBody) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

View File

@@ -1,4 +1,5 @@
const { removeNullishValues } = require('librechat-data-provider');
const artifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
@@ -10,8 +11,10 @@ const buildOptions = (endpoint, parsedBody) => {
iconURL,
greeting,
spec,
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = removeNullishValues({
endpoint,
chatGptLabel,
@@ -25,6 +28,10 @@ const buildOptions = (endpoint, parsedBody) => {
modelOptions,
});
if (artifacts === 'default') {
endpointOption.promptPrefix = `${promptPrefix ?? ''}\n${artifactsPrompt}`.trim();
}
return endpointOption;
};

BIN
bun.lockb

Binary file not shown.

View File

@@ -29,6 +29,7 @@
"homepage": "https://librechat.ai",
"dependencies": {
"@ariakit/react": "^0.4.8",
"@codesandbox/sandpack-react": "^2.18.2",
"@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4",
"@headlessui/react": "^2.1.2",
@@ -79,19 +80,22 @@
"react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^8.0.6",
"react-resizable-panels": "^1.0.9",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.1",
"react-router-dom": "^6.11.2",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.6.1",
"recoil": "^0.7.7",
"regenerator-runtime": "^0.14.1",
"rehype-highlight": "^6.0.0",
"rehype-katex": "^6.0.2",
"rehype-raw": "^6.1.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"rehype-mermaid": "^2.1.0",
"rehype-raw": "^7.0.0",
"remark-directive": "^3.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-supersub": "^1.0.0",
"tailwind-merge": "^1.9.1",
"tailwindcss-animate": "^1.0.5",

View File

@@ -0,0 +1,15 @@
export interface CodeBlock {
id: string;
language: string;
content: string;
}
export interface Artifact {
id: string;
lastUpdateTime: number;
identifier?: string;
language?: string;
content?: string;
title?: string;
type?: string;
}

View File

@@ -1,2 +1,3 @@
export * from './artifacts';
export * from './types';
export * from './assistants-types';

View File

@@ -0,0 +1,104 @@
import React, { useEffect, useCallback, useRef, useState } from 'react';
import throttle from 'lodash/throttle';
import { visit } from 'unist-util-visit';
import { useSetRecoilState } from 'recoil';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { artifactsState } from '~/store/artifacts';
import ArtifactButton from './ArtifactButton';
import { logger } from '~/utils';
export const artifactPlugin: Pluggable = () => {
return (tree) => {
visit(tree, ['textDirective', 'leafDirective', 'containerDirective'], (node) => {
node.data = {
hName: node.name,
hProperties: node.attributes,
...node.data,
};
return node;
});
};
};
const extractContent = (
children: React.ReactNode | { props: { children: React.ReactNode } } | string,
): string => {
if (typeof children === 'string') {
return children;
}
if (React.isValidElement(children)) {
return extractContent((children.props as { children?: React.ReactNode }).children);
}
if (Array.isArray(children)) {
return children.map(extractContent).join('');
}
return '';
};
export function Artifact({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
node,
...props
}: Artifact & {
children: React.ReactNode | { props: { children: React.ReactNode } };
node: unknown;
}) {
const setArtifacts = useSetRecoilState(artifactsState);
const [artifact, setArtifact] = useState<Artifact | null>(null);
const throttledUpdateRef = useRef(
throttle((updateFn: () => void) => {
updateFn();
}, 25),
);
const updateArtifact = useCallback(() => {
const content = extractContent(props.children);
logger.log('artifacts', 'updateArtifact: content.length', content.length);
if (!content || content.trim() === '') {
return;
}
const title = props.title ?? 'Untitled Artifact';
const type = props.type ?? 'unknown';
const identifier = props.identifier ?? 'no-identifier';
const artifactKey = `${identifier}_${type}_${title}`.replace(/\s+/g, '_').toLowerCase();
throttledUpdateRef.current(() => {
const now = Date.now();
const currentArtifact: Artifact = {
id: artifactKey,
identifier,
title,
type,
content,
lastUpdateTime: now,
};
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
prevArtifacts[artifactKey].content === content
) {
return prevArtifacts;
}
return {
...prevArtifacts,
[artifactKey]: currentArtifact,
};
});
setArtifact(currentArtifact);
});
}, [props.type, props.title, setArtifacts, props.children, props.identifier]);
useEffect(() => {
updateArtifact();
}, [updateArtifact]);
return <ArtifactButton artifact={artifact} />;
}

View File

@@ -0,0 +1,44 @@
import { useSetRecoilState } from 'recoil';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { useLocalize } from '~/hooks';
import { getFileType } from '~/utils';
import store from '~/store';
const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
const localize = useLocalize();
const setVisible = useSetRecoilState(store.artifactsVisible);
const setArtifactId = useSetRecoilState(store.currentArtifactId);
if (artifact === null || artifact === undefined) {
return null;
}
const fileType = getFileType('artifact');
return (
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
<button
type="button"
onClick={() => {
setArtifactId(artifact.id);
setVisible(true);
}}
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
>
<div className="w-60 bg-surface-tertiary p-2 ">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">
{localize('com_ui_artifact_click')}
</div>
</div>
</div>
</div>
</button>
<br />
</div>
);
};
export default ArtifactButton;

View File

@@ -0,0 +1,70 @@
import React, { useMemo, memo } from 'react';
import { Sandpack } from '@codesandbox/sandpack-react';
import { removeNullishValues } from 'librechat-data-provider';
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { Artifact } from '~/common';
import {
sharedFiles,
sharedProps,
getTemplate,
sharedOptions,
getArtifactFilename,
} from '~/utils/artifacts';
export const ArtifactPreview = memo(function ({
showEditor = false,
artifact,
previewRef,
}: {
showEditor?: boolean;
artifact: Artifact;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) {
const files = useMemo(() => {
return removeNullishValues({ [getArtifactFilename(artifact.type ?? '')]: artifact.content });
}, [artifact.type, artifact.content]);
const template = useMemo(
() => getTemplate(artifact.type ?? '', artifact.language),
[artifact.type, artifact.language],
);
if (Object.keys(files).length === 0) {
return null;
}
return showEditor ? (
<Sandpack
options={{
showNavigator: true,
editorHeight: '80vh',
showTabs: true,
...sharedOptions,
}}
files={{
...files,
...sharedFiles,
}}
{...sharedProps}
template={template}
/>
) : (
<SandpackProvider
files={{
...files,
...sharedFiles,
}}
options={{ ...sharedOptions }}
{...sharedProps}
template={template}
>
<SandpackPreview
showOpenInCodeSandbox={false}
showRefreshButton={false}
tabIndex={0}
ref={previewRef}
/>
</SandpackProvider>
);
});

View File

@@ -0,0 +1,201 @@
import { useRef, useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import { CodeMarkdown, CopyCodeButton } from './Code';
import { getFileExtension } from '~/utils/artifacts';
import { ArtifactPreview } from './ArtifactPreview';
import { cn } from '~/utils';
import store from '~/store';
export default function Artifacts() {
const previewRef = useRef<SandpackPreviewRef>();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
useEffect(() => {
setIsVisible(true);
}, []);
const {
activeTab,
isSubmitting,
setActiveTab,
currentIndex,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
} = useArtifacts();
if (currentArtifact === null || currentArtifact === undefined) {
return null;
}
const handleRefresh = () => {
setIsRefreshing(true);
const client = previewRef.current?.getClient();
if (client != null) {
client.dispatch({ type: 'refresh' });
}
setTimeout(() => setIsRefreshing(false), 750);
};
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
{/* Main Parent */}
<div className="flex h-full w-full items-center justify-center py-2">
{/* Main Container */}
<div
className={`flex h-[97%] w-[97%] flex-col overflow-hidden rounded-xl border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
isVisible
? 'translate-x-0 scale-100 opacity-100'
: 'translate-x-full scale-95 opacity-0'
}`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
<div className="flex items-center">
<button
className="mr-2 text-text-secondary"
onClick={() => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
</svg>
</button>
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
</div>
<div className="flex items-center">
{/* Refresh button */}
{activeTab === 'preview' && (
<button
className={`mr-2 text-text-secondary transition-transform duration-500 ease-in-out ${
isRefreshing ? 'rotate-180' : ''
}`}
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="Refresh"
>
<RefreshCw
size={16}
className={`transform ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
)}
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
<Tabs.Trigger
value="preview"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
Preview
</Tabs.Trigger>
<Tabs.Trigger
value="code"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
Code
</Tabs.Trigger>
</Tabs.List>
<button
className="text-text-secondary"
onClick={() => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
</button>
</div>
</div>
{/* Content */}
<Tabs.Content
value="code"
className={cn('flex-grow overflow-x-auto overflow-y-scroll bg-gray-900 p-4')}
>
<CodeMarkdown
content={`\`\`\`${getFileExtension(currentArtifact.type)}\n${
currentArtifact.content ?? ''
}\`\`\``}
isSubmitting={isSubmitting}
/>
</Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto bg-white">
<ArtifactPreview
artifact={currentArtifact}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
</Tabs.Content>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
</svg>
</button>
<span className="text-xs">{`${currentIndex + 1} / ${
orderedArtifactIds.length
}`}</span>
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
</svg>
</button>
</div>
<div className="flex items-center">
<CopyCodeButton content={currentArtifact.content ?? ''} />
{/* Download Button */}
{/* <button className="mr-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z" />
</svg>
</button> */}
{/* Publish button */}
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
Publish
</button> */}
</div>
</div>
</div>
</div>
</Tabs.Root>
);
}

View File

@@ -0,0 +1,119 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import copy from 'copy-to-clipboard';
import { handleDoubleClick, langSubset } from '~/utils';
import Clipboard from '~/components/svg/Clipboard';
import CheckMark from '~/components/svg/CheckMark';
import useLocalize from '~/hooks/useLocalize';
type TCodeProps = {
inline: boolean;
className?: string;
children: React.ReactNode;
};
export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
if (inline) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
{children}
</code>
);
}
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
});
export const CodeMarkdown = memo(
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const currentContent = content;
const rehypePlugins = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled]);
return (
<div ref={scrollRef} className="max-h-full overflow-y-auto">
<ReactMarkdown
/* @ts-ignore */
rehypePlugins={rehypePlugins}
components={
{ code } as {
[key: string]: React.ElementType;
}
}
>
{currentContent}
</ReactMarkdown>
</div>
);
},
);
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const handleCopy = () => {
copy(content, { format: 'text/plain' });
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
};
return (
<button
className="mr-2 text-text-secondary"
onClick={handleCopy}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
</button>
);
};

View File

@@ -0,0 +1,33 @@
import React, { useEffect, useRef } from 'react';
import mermaid from 'mermaid';
interface MermaidProps {
chart: string;
}
const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
const mermaidRef = useRef<HTMLDivElement>(null);
useEffect(() => {
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'strict',
});
mermaid.contentLoaded();
}, []);
useEffect(() => {
if (chart && mermaidRef.current) {
mermaid.render('mermaid-svg', chart).then((svgCode) => {
if (mermaidRef.current) {
mermaidRef.current.innerHTML = svgCode.svg;
}
});
}
}, [chart]);
return <div ref={mermaidRef} />;
};
export default Mermaid;

View File

@@ -0,0 +1,230 @@
import dedent from 'dedent';
import { Sandpack } from '@codesandbox/sandpack-react';
import {
SandpackPreview,
SandpackProvider,
} from '@codesandbox/sandpack-react/unstyled';
// import './code-viewer.css';
const App = `import React, { useState } from 'react';
import './styles.css';
function App() {
const [result, setResult] = useState('');
const handleClick = (e) => {
setResult(result.concat(e.target.name));
}
const clear = () => {
setResult('');
}
const backspace = () => {
setResult(result.slice(0, -1));
}
const calculate = () => {
try {
setResult(eval(result).toString());
} catch(err) {
setResult('Error');
}
}
return (
<div className="calculator">
<input type="text" value={result} />
<div className="keypad">
<button className="highlight" onClick={clear} id="clear">Clear</button>
<button className="highlight" onClick={backspace} id="backspace">C</button>
<button className="highlight" name="/" onClick={handleClick}>&divide;</button>
<button name="7" onClick={handleClick}>7</button>
<button name="8" onClick={handleClick}>8</button>
<button name="9" onClick={handleClick}>9</button>
<button className="highlight" name="*" onClick={handleClick}>&times;</button>
<button name="4" onClick={handleClick}>4</button>
<button name="5" onClick={handleClick}>5</button>
<button name="6" onClick={handleClick}>6</button>
<button className="highlight" name="-" onClick={handleClick}>&ndash;</button>
<button name="1" onClick={handleClick}>1</button>
<button name="2" onClick={handleClick}>2</button>
<button name="3" onClick={handleClick}>3</button>
<button className="highlight" name="+" onClick={handleClick}>+</button>
<button name="0" onClick={handleClick}>0</button>
<button name="." onClick={handleClick}>.</button>
<button className="highlight" onClick={calculate} id="result">=</button>
</div>
</div>
);
}
export default App;`;
const styles = `
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.calculator {
width: 320px;
margin: 100px auto;
}
input[type="text"] {
width: 100%;
height: 60px;
font-size: 20px;
text-align: right;
padding: 0 10px;
pointer-events: none;
}
.keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 10px;
padding: 10px;
}
button {
width: 100%;
height: 60px;
font-size: 18px;
border: none;
border-radius: 5px;
cursor: pointer;
background-color: #f0f0f0;
}
button:hover {
background-color: #ddd;
}
.highlight {
background-color: #ff8c00;
color: white;
}
.highlight:hover {
background-color: #e67e00;
}
`;
export function DevCodeViewer({
code,
showEditor = false,
}: {
code?: string;
showEditor?: boolean;
}) {
return showEditor ? (
<Sandpack
options={{
showNavigator: true,
editorHeight: '80vh',
showTabs: true,
...sharedOptions,
}}
files={{
// 'App.tsx': code,
'App.tsx': App,
...sharedFiles,
'styles.css': styles,
}}
{...sharedProps}
/>
) : (
<SandpackProvider
files={{
// 'App.tsx': code,
'App.tsx': App,
...sharedFiles,
'styles.css': styles,
}}
className="flex h-full w-full grow flex-col justify-center"
options={{ ...sharedOptions }}
{...sharedProps}
>
<SandpackPreview
className="flex h-full w-full grow flex-col justify-center p-4 md:pt-16"
showOpenInCodeSandbox={false}
showRefreshButton={false}
/>
</SandpackProvider>
);
}
const sharedProps = {
template: 'react-ts',
// theme: draculaTheme,
customSetup: {
dependencies: {
'lucide-react': '^0.394.0',
'react-router-dom': '^6.11.2',
'class-variance-authority': '^0.6.0',
clsx: '^1.2.1',
'date-fns': '^3.3.1',
'tailwind-merge': '^1.9.1',
'tailwindcss-animate': '^1.0.5',
// recharts: '2.9.0',
// '@radix-ui/react-accordion': '^1.2.0',
// '@radix-ui/react-alert-dialog': '^1.1.1',
// '@radix-ui/react-aspect-ratio': '^1.1.0',
// '@radix-ui/react-avatar': '^1.1.0',
// '@radix-ui/react-checkbox': '^1.1.1',
// '@radix-ui/react-collapsible': '^1.1.0',
// '@radix-ui/react-dialog': '^1.1.1',
// '@radix-ui/react-dropdown-menu': '^2.1.1',
// '@radix-ui/react-hover-card': '^1.1.1',
// '@radix-ui/react-label': '^2.1.0',
// '@radix-ui/react-menubar': '^1.1.1',
// '@radix-ui/react-navigation-menu': '^1.2.0',
// '@radix-ui/react-popover': '^1.1.1',
// '@radix-ui/react-progress': '^1.1.0',
// '@radix-ui/react-radio-group': '^1.2.0',
// '@radix-ui/react-select': '^2.1.1',
// '@radix-ui/react-separator': '^1.1.0',
// '@radix-ui/react-slider': '^1.2.0',
// '@radix-ui/react-slot': '^1.1.0',
// '@radix-ui/react-switch': '^1.1.0',
// '@radix-ui/react-tabs': '^1.1.0',
// '@radix-ui/react-toast': '^1.2.1',
// '@radix-ui/react-toggle': '^1.1.0',
// '@radix-ui/react-toggle-group': '^1.1.0',
// '@radix-ui/react-tooltip': '^1.1.2',
// 'embla-carousel-react': '^8.1.8',
// 'react-day-picker': '^8.10.1',
// vaul: '^0.9.1',
},
},
} as const;
const sharedOptions = {
externalResources: [
'https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css',
],
};
const sharedFiles = {
'/public/index.html': dedent`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
`,
};

View File

@@ -0,0 +1,37 @@
// client/src/hooks/useDebounceCodeBlock.ts
import { useCallback, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useSetRecoilState } from 'recoil';
import { codeBlocksState, codeBlockIdsState } from '~/store/artifacts';
import type { CodeBlock } from '~/common';
export function useDebounceCodeBlock() {
const setCodeBlocks = useSetRecoilState(codeBlocksState);
const setCodeBlockIds = useSetRecoilState(codeBlockIdsState);
const updateCodeBlock = useCallback((codeBlock: CodeBlock) => {
console.log('Updating code block:', codeBlock);
setCodeBlocks((prev) => ({
...prev,
[codeBlock.id]: codeBlock,
}));
setCodeBlockIds((prev) =>
prev.includes(codeBlock.id) ? prev : [...prev, codeBlock.id],
);
}, [setCodeBlocks, setCodeBlockIds]);
const debouncedUpdateCodeBlock = useCallback(
debounce((codeBlock: CodeBlock) => {
updateCodeBlock(codeBlock);
}, 25),
[updateCodeBlock],
);
useEffect(() => {
return () => {
debouncedUpdateCodeBlock.cancel();
};
}, [debouncedUpdateCodeBlock]);
return debouncedUpdateCodeBlock;
}

View File

@@ -5,10 +5,15 @@ import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import { useRecoilValue } from 'recoil';
import ReactMarkdown from 'react-markdown';
import type { PluggableList } from 'unified';
import rehypeHighlight from 'rehype-highlight';
import remarkDirective from 'remark-directive';
import type { PluggableList, Pluggable } from 'unified';
import type { Pluggable } from 'unified';
import { CodeBlockArtifact, CodeMarkdown } from '~/components/Artifacts/Code';
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import { preprocessCodeArtifacts } from '~/utils/artifacts';
import { useFileDownload } from '~/data-provider';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
@@ -20,11 +25,13 @@ type TCodeProps = {
children: React.ReactNode;
};
export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => {
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
if (inline) {
if (lang === 'math') {
return children;
} else if (typeof children === 'string' && children.split('\n').length === 1) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
{children}
@@ -35,73 +42,75 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
}
});
export const a = memo(({ href, children }: { href: string; children: React.ReactNode }) => {
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const localize = useLocalize();
export const a: React.ElementType = memo(
({ href, children }: { href: string; children: React.ReactNode }) => {
const user = useRecoilValue(store.user);
const { showToast } = useToastContext();
const localize = useLocalize();
const { file_id, filename, filepath } = useMemo(() => {
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
const match = href.match(pattern);
if (match && match[0]) {
const path = match[0];
const parts = path.split('/');
const name = parts.pop();
const file_id = parts.pop();
return { file_id, filename: name, filepath: path };
const { file_id, filename, filepath } = useMemo(() => {
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
const match = href.match(pattern);
if (match && match[0]) {
const path = match[0];
const parts = path.split('/');
const name = parts.pop();
const file_id = parts.pop();
return { file_id, filename: name, filepath: path };
}
return { file_id: '', filename: '', filepath: '' };
}, [user?.id, href]);
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
if (!file_id || !filename) {
return (
<a href={href} {...props}>
{children}
</a>
);
}
return { file_id: '', filename: '', filepath: '' };
}, [user?.id, href]);
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id);
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
try {
const stream = await downloadFile();
if (stream.data == null || stream.data === '') {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
}
};
props.onClick = handleDownload;
props.target = '_blank';
if (!file_id || !filename) {
return (
<a href={href} {...props}>
<a
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
{children}
</a>
);
}
},
);
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
try {
const stream = await downloadFile();
if (!stream.data) {
console.error('Error downloading file: No data found');
showToast({
status: 'error',
message: localize('com_ui_download_error'),
});
return;
}
const link = document.createElement('a');
link.href = stream.data;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(stream.data);
} catch (error) {
console.error('Error downloading file:', error);
}
};
props.onClick = handleDownload;
props.target = '_blank';
return (
<a
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
{children}
</a>
);
});
export const p = memo(({ children }: { children: React.ReactNode }) => {
export const p: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
@@ -114,7 +123,10 @@ type TContentProps = {
};
const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentProps) => {
const artifactIdRef = useRef<string | null>(null);
const codeBlocksRef = useRef<number | null>(null);
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
const codeArtifacts = useRecoilValue<boolean>(store.codeArtifacts);
const isInitializing = content === '';
@@ -122,9 +134,10 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
if (!isInitializing) {
currentContent = currentContent.replace('z-index: 1;', '') || '';
currentContent = LaTeXParsing ? preprocessLaTeX(currentContent) : currentContent;
currentContent = codeArtifacts ? preprocessCodeArtifacts(currentContent) : currentContent;
}
const rehypePlugins: PluggableList = [
const rehypePlugins = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
@@ -146,16 +159,29 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
);
}
const remarkPlugins: Pluggable[] = codeArtifacts
? [
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
remarkDirective,
artifactPlugin,
]
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
return (
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
/** @ts-ignore */
remarkPlugins={remarkPlugins}
/* @ts-ignore */
rehypePlugins={rehypePlugins}
linkTarget="_new"
// linkTarget="_new"
components={
{
code,
a,
p,
artifact: Artifact,
} as {
[nodeType: string]: React.ElementType;
}

View File

@@ -26,7 +26,7 @@ const MarkdownLite = memo(({ content = '' }: { content?: string }) => {
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins}
linkTarget="_new"
// linkTarget="_new"
components={
{
code,

View File

@@ -6,6 +6,7 @@ import type { ExtendedFile } from '~/common';
import { useDragHelpers, useSetFilesToDelete } from '~/hooks';
import DragDropOverlay from './Input/Files/DragDropOverlay';
import { useDeleteFilesMutation } from '~/data-provider';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanel } from '~/components/SidePanel';
import store from '~/store';
@@ -21,7 +22,11 @@ export default function Presentation({
useSidePanel?: boolean;
}) {
const { data: startupConfig } = useGetStartupConfig();
const artifacts = useRecoilValue(store.artifactsState);
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const hideSidePanel = useRecoilValue(store.hideSidePanel);
const artifactsVisible = useRecoilValue(store.artifactsVisible);
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
@@ -44,12 +49,15 @@ export default function Presentation({
const filesToDelete = localStorage.getItem(LocalStorageKeys.FILES_TO_DELETE);
const map = JSON.parse(filesToDelete ?? '{}') as Record<string, ExtendedFile>;
const files = Object.values(map)
.filter((file) => file.filepath && file.source && !file.embedded && file.temp_file_id)
.filter(
(file) =>
file.filepath != null && file.source && !(file.embedded ?? false) && file.temp_file_id,
)
.map((file) => ({
file_id: file.file_id,
filepath: file.filepath as string,
source: file.source as FileSources,
embedded: !!file.embedded,
embedded: !!(file.embedded ?? false),
}));
if (files.length === 0) {
@@ -89,6 +97,13 @@ export default function Presentation({
defaultLayout={defaultLayout}
defaultCollapsed={defaultCollapsed}
fullPanelCollapse={fullCollapse}
artifacts={
artifactsVisible === true &&
codeArtifacts === true &&
Object.keys(artifacts ?? {}).length > 0 ? (
<Artifacts />
) : null
}
>
<main className="flex h-full flex-col" role="main">
{children}
@@ -102,7 +117,7 @@ export default function Presentation({
return (
<div ref={drop} className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
{layout()}
{panel && panel}
{panel != null && panel}
</div>
);
}

View File

@@ -75,7 +75,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
return (
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
<CodeBar lang={lang} codeRef={codeRef} plugin={plugin === true} error={error} />
<CodeBar lang={lang} codeRef={codeRef} plugin={(plugin === true ?? false)} error={error} />
<div className={cn(classProp, 'overflow-y-auto p-4')}>
<code
ref={codeRef}

View File

@@ -1,6 +1,7 @@
import { memo } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import CodeArtifacts from './CodeArtifacts';
import LaTeXParsing from './LaTeXParsing';
import ModularChat from './ModularChat';
@@ -18,6 +19,9 @@ function Beta() {
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<LaTeXParsing />
</div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<CodeArtifacts />
</div>
</div>
</Tabs.Content>
);

View File

@@ -0,0 +1,37 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function CodeArtifactsSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [codeArtifacts, setCodeArtifacts] = useRecoilState<boolean>(store.codeArtifacts);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setCodeArtifacts(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_ui_artifacts_toggle')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_code_artifacts" />
</div>
<Switch
id="codeArtifacts"
checked={codeArtifacts}
onCheckedChange={handleCheckedChange}
className="ml-4 mt-2"
data-testid="codeArtifacts"
/>
</div>
);
}

View File

@@ -23,17 +23,37 @@ interface SidePanelProps {
defaultCollapsed?: boolean;
navCollapsedSize?: number;
fullPanelCollapse?: boolean;
artifacts?: React.ReactNode;
children: React.ReactNode;
}
const defaultMinSize = 20;
const defaultInterface = getConfigDefaults().interface;
const normalizeLayout = (layout: number[]) => {
const sum = layout.reduce((acc, size) => acc + size, 0);
if (Math.abs(sum - 100) < 0.01) {
return layout.map((size) => Number(size.toFixed(2)));
}
const factor = 100 / sum;
const normalizedLayout = layout.map((size) => Number((size * factor).toFixed(2)));
const adjustedSum = normalizedLayout.reduce(
(acc, size, index) => (index === layout.length - 1 ? acc : acc + size),
0,
);
normalizedLayout[normalizedLayout.length - 1] = Number((100 - adjustedSum).toFixed(2));
return normalizedLayout;
};
const SidePanel = ({
defaultLayout = [97, 3],
defaultCollapsed = false,
fullPanelCollapse = false,
navCollapsedSize = 3,
artifacts,
children,
}: SidePanelProps) => {
const localize = useLocalize();
@@ -64,11 +84,11 @@ const SidePanel = ({
const assistants = useMemo(() => endpointsConfig?.[endpoint ?? ''], [endpoint, endpointsConfig]);
const userProvidesKey = useMemo(
() => !!endpointsConfig?.[endpoint ?? '']?.userProvide,
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!keyExpiry.expiresAt : true),
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
@@ -89,10 +109,26 @@ const SidePanel = ({
interfaceConfig,
});
const calculateLayout = useCallback(() => {
if (!artifacts) {
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
return [100 - navSize, navSize];
} else {
const navSize = Math.max(minSize, navCollapsedSize);
const remainingSpace = 100 - navSize;
const newMainSize = Math.floor(remainingSpace / 2);
const artifactsSize = remainingSpace - newMainSize;
return [newMainSize, artifactsSize, navSize];
}
}, [artifacts, defaultLayout, minSize, navCollapsedSize]);
const currentLayout = useMemo(() => normalizeLayout(calculateLayout()), [calculateLayout]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledSaveLayout = useCallback(
throttle((sizes: number[]) => {
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(sizes));
const normalizedSizes = normalizeLayout(sizes);
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes));
}, 350),
[],
);
@@ -133,17 +169,37 @@ const SidePanel = ({
}
}, [isCollapsed, newUser, setNewUser, navCollapsedSize]);
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
return (
<>
<TooltipProvider delayDuration={0}>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes: number[]) => throttledSaveLayout(sizes)}
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="transition-width relative h-full w-full flex-1 overflow-auto bg-white dark:bg-gray-800"
>
<ResizablePanel defaultSize={defaultLayout[0]} minSize={30}>
<ResizablePanel
defaultSize={currentLayout[0]}
minSize={minSizeMain}
order={1}
id="messages-view"
>
{children}
</ResizablePanel>
{artifacts != null && (
<>
<ResizableHandleAlt withHandle className="ml-3 bg-border-medium dark:text-white" />
<ResizablePanel
defaultSize={currentLayout[1]}
minSize={minSizeMain}
order={2}
id="artifacts-panel"
>
{artifacts}
</ResizablePanel>
</>
)}
<TooltipProvider delayDuration={400}>
<Tooltip>
<div
@@ -174,10 +230,11 @@ const SidePanel = ({
<ResizablePanel
tagName="nav"
id="controls-nav"
order={artifacts != null ? 3 : 2}
aria-label={localize('com_ui_controls')}
role="region"
collapsedSize={collapsedSize}
defaultSize={defaultLayout[1]}
defaultSize={currentLayout[currentLayout.length - 1]}
collapsible={true}
minSize={minSize}
maxSize={40}

View File

@@ -0,0 +1,116 @@
import { useMemo, useState, useEffect, useRef } from 'react';
import { Constants } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil';
import { useChatContext } from '~/Providers';
import { getLatestText } from '~/utils';
import store from '~/store';
export default function useArtifacts() {
const { isSubmitting, latestMessage, conversation } = useChatContext();
const [activeTab, setActiveTab] = useState('preview');
const artifacts = useRecoilValue(store.artifactsState);
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
const resetArtifacts = useResetRecoilState(store.artifactsState);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const orderedArtifactIds = useMemo(() => {
return Object.keys(artifacts ?? {}).sort(
(a, b) => (artifacts?.[a]?.lastUpdateTime ?? 0) - (artifacts?.[b]?.lastUpdateTime ?? 0),
);
}, [artifacts]);
const lastRunMessageIdRef = useRef<string | null>(null);
const lastContentRef = useRef<string | null>(null);
const prevConversationIdRef = useRef<string | null>(null);
const hasEnclosedArtifactRef = useRef<boolean>(false);
const hasAutoSwitchedToCodeRef = useRef<boolean>(false);
useEffect(() => {
const resetState = () => {
resetArtifacts();
resetCurrentArtifactId();
prevConversationIdRef.current = conversation?.conversationId ?? null;
lastRunMessageIdRef.current = null;
lastContentRef.current = null;
hasEnclosedArtifactRef.current = false;
};
if (
conversation &&
conversation.conversationId !== prevConversationIdRef.current &&
prevConversationIdRef.current != null
) {
resetState();
} else if (conversation && conversation.conversationId === Constants.NEW_CONVO) {
resetState();
}
prevConversationIdRef.current = conversation?.conversationId ?? null;
}, [conversation, resetArtifacts, resetCurrentArtifactId]);
useEffect(() => {
if (orderedArtifactIds.length > 0) {
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
setCurrentArtifactId(latestArtifactId);
}
}, [setCurrentArtifactId, orderedArtifactIds]);
useEffect(() => {
if (isSubmitting && orderedArtifactIds.length > 0 && latestMessage) {
const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1];
const latestArtifact = artifacts?.[latestArtifactId];
if (latestArtifact?.content !== lastContentRef.current) {
setCurrentArtifactId(latestArtifactId);
lastContentRef.current = latestArtifact?.content ?? null;
const latestMessageText = getLatestText(latestMessage);
const hasEnclosedArtifact = /:::artifact[\s\S]*?(```|:::)\s*$/.test(
latestMessageText.trim(),
);
if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) {
setActiveTab('preview');
hasEnclosedArtifactRef.current = true;
hasAutoSwitchedToCodeRef.current = false;
} else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) {
const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? '';
if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) {
setActiveTab('code');
hasAutoSwitchedToCodeRef.current = true;
}
}
}
}
}, [setCurrentArtifactId, isSubmitting, orderedArtifactIds, artifacts, latestMessage]);
useEffect(() => {
if (latestMessage?.messageId !== lastRunMessageIdRef.current) {
lastRunMessageIdRef.current = latestMessage?.messageId ?? null;
hasEnclosedArtifactRef.current = false;
hasAutoSwitchedToCodeRef.current = false;
}
}, [latestMessage]);
const currentArtifact = currentArtifactId != null ? artifacts?.[currentArtifactId] : null;
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');
const cycleArtifact = (direction: 'next' | 'prev') => {
let newIndex: number;
if (direction === 'next') {
newIndex = (currentIndex + 1) % orderedArtifactIds.length;
} else {
newIndex = (currentIndex - 1 + orderedArtifactIds.length) % orderedArtifactIds.length;
}
setCurrentArtifactId(orderedArtifactIds[newIndex]);
};
return {
activeTab,
setActiveTab,
currentIndex,
isSubmitting,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
};
}

View File

@@ -0,0 +1,30 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useChatContext } from '~/Providers';
export default function useAutoScroll() {
const { isSubmitting } = useChatContext();
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const contentEndRef = useRef<HTMLDivElement | null>(null);
const scrollToBottom = useCallback(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = scrollableRef.current.scrollHeight;
}
}, []);
const handleScroll = useCallback(() => {
if (scrollableRef.current) {
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100);
}
}, []);
useEffect(() => {
if (isSubmitting) {
scrollToBottom();
}
}, [isSubmitting, scrollToBottom]);
return { scrollableRef, contentEndRef, handleScroll, scrollToBottom, showScrollButton };
}

View File

@@ -7,7 +7,7 @@ import {
parseCompactConvo,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { useSetRecoilState, useResetRecoilState, useRecoilValue } from 'recoil';
import type {
TMessage,
TSubmission,
@@ -47,6 +47,7 @@ export default function useChatFunctions({
setSubmission: SetterOrUpdater<TSubmission | null>;
setLatestMessage?: SetterOrUpdater<TMessage | null>;
}) {
const artifacts = useRecoilValue(store.codeArtifacts);
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const setFilesToDelete = useSetFilesToDelete();
@@ -152,6 +153,7 @@ export default function useChatFunctions({
key: getExpiry(),
modelDisplayLabel,
overrideUserMessageId,
artifacts: artifacts === true ? 'default' : undefined,
} as TEndpointOption;
const responseSender = getSender({ model: conversation?.model, ...endpointOption });

View File

@@ -3,6 +3,8 @@
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets present in this file
export default {
com_ui_artifacts_toggle: 'Toggle Artifacts Mode',
com_ui_artifact_click: 'Click to open',
com_a11y_start: 'The AI is replying.',
com_a11y_end: 'The AI has finished their reply.',
com_error_moderation:

View File

@@ -312,3 +312,19 @@
.chrome-scrollbar::-webkit-scrollbar-track {
background-color: transparent; /* Color of the tracking area */
}
.sp-preview-container {
@apply flex h-full w-full grow flex-col justify-center;
}
.sp-preview {
@apply flex h-full w-full grow flex-col justify-center;
}
.sp-preview-iframe {
@apply grow;
}
.sp-wrapper {
@apply flex h-full w-full grow flex-col justify-center;
}

View File

@@ -0,0 +1,48 @@
import { atom } from 'recoil';
import { logger } from '~/utils';
import type { Artifact } from '~/common';
export const artifactsState = atom<Record<string, Artifact | undefined> | null>({
key: 'artifactsState',
default: null,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
logger.log('artifacts', 'Recoil Effect: Setting artifactsState', {
key: node.key,
newValue,
});
});
},
] as const,
});
export const currentArtifactId = atom<string | null>({
key: 'currentArtifactId',
default: null,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
logger.log('artifacts', 'Recoil Effect: Setting currentArtifactId', {
key: node.key,
newValue,
});
});
},
] as const,
});
export const artifactsVisible = atom<boolean>({
key: 'artifactsVisible',
default: true,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
logger.log('artifacts', 'Recoil Effect: Setting artifactsVisible', {
key: node.key,
newValue,
});
});
},
] as const,
});

View File

@@ -1,3 +1,4 @@
import * as artifacts from './artifacts';
import conversation from './conversation';
import conversations from './conversations';
import families from './families';
@@ -13,6 +14,7 @@ import lang from './language';
import settings from './settings';
export default {
...artifacts,
...families,
...conversation,
...conversations,

View File

@@ -37,6 +37,7 @@ const localStorageAtoms = {
// Beta features settings
modularChat: atomWithLocalStorage('modularChat', true),
LaTeXParsing: atomWithLocalStorage('LaTeXParsing', true),
codeArtifacts: atomWithLocalStorage('codeArtifacts', false),
// Commands settings
atCommand: atomWithLocalStorage('atCommand', true),

View File

@@ -0,0 +1,94 @@
import { preprocessCodeArtifacts } from './artifacts';
describe('preprocessCodeArtifacts', () => {
test('should return non-string inputs unchanged', () => {
expect(preprocessCodeArtifacts(123 as unknown as string)).toBe('');
expect(preprocessCodeArtifacts(null as unknown as string)).toBe('');
expect(preprocessCodeArtifacts(undefined)).toBe('');
expect(preprocessCodeArtifacts({} as unknown as string)).toEqual('');
});
test('should remove <thinking> tags and their content', () => {
const input = '<thinking>This should be removed</thinking>Some content';
const expected = 'Some content';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should remove unclosed <thinking> tags and their content', () => {
const input = '<thinking>This should be removed\nSome content';
const expected = '';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should remove artifact headers up to and including empty code block', () => {
const input = ':::artifact{identifier="test"}\n```\n```\nSome content';
const expected = ':::artifact{identifier="test"}\n```\n```\nSome content';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should keep artifact headers when followed by empty code block and content', () => {
const input = ':::artifact{identifier="test"}\n```\n```\nSome content';
const expected = ':::artifact{identifier="test"}\n```\n```\nSome content';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should handle multiple artifact headers correctly', () => {
const input = ':::artifact{id="1"}\n```\n```\n:::artifact{id="2"}\n```\ncode\n```\nContent';
const expected = ':::artifact{id="1"}\n```\n```\n:::artifact{id="2"}\n```\ncode\n```\nContent';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should handle complex input with multiple patterns', () => {
const input = `
<thinking>Remove this</thinking>
Some text
:::artifact{id="1"}
\`\`\`
\`\`\`
<thinking>And this</thinking>
:::artifact{id="2"}
\`\`\`
keep this code
\`\`\`
More text
`;
const expected = `
Some text
:::artifact{id="1"}
\`\`\`
\`\`\`
:::artifact{id="2"}
\`\`\`
keep this code
\`\`\`
More text
`;
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should remove artifact headers without code blocks', () => {
const input = ':::artifact{identifier="test"}\nSome content without code block';
const expected = '';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should remove artifact headers up to incomplete code block', () => {
const input = ':::artifact{identifier="react-cal';
const expected = '';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should keep artifact headers when any character follows code block', () => {
const input = ':::artifact{identifier="react-calculator"}\n```t';
const expected = ':::artifact{identifier="react-calculator"}\n```t';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
test('should keep artifact headers when whitespace follows code block', () => {
const input = ':::artifact{identifier="react-calculator"}\n``` ';
const expected = ':::artifact{identifier="react-calculator"}\n``` ';
expect(preprocessCodeArtifacts(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,178 @@
import dedent from 'dedent';
import type {
SandpackProviderProps,
SandpackPredefinedTemplate,
} from '@codesandbox/sandpack-react';
// import * as shadcnComponents from '~/utils/shadcn';
const artifactFilename = {
'application/vnd.react': 'App.tsx',
'text/html': 'index.html',
// 'css': 'css',
// 'javascript': 'js',
// 'typescript': 'ts',
// 'jsx': 'jsx',
// 'tsx': 'tsx',
};
const artifactTemplate: Record<string, SandpackPredefinedTemplate | undefined> = {
'text/html': 'static',
'application/vnd.react': 'react-ts',
'application/vnd.code-html': 'static',
// 'css': 'css',
// 'javascript': 'js',
// 'typescript': 'ts',
// 'jsx': 'jsx',
// 'tsx': 'tsx',
};
export function getArtifactFilename(type: string): string {
return artifactFilename[type] ?? 'App.tsx';
}
export function getFileExtension(language?: string): string {
switch (language) {
case 'application/vnd.react':
return 'tsx';
case 'text/html':
return 'html';
// case 'jsx':
// return 'jsx';
// case 'tsx':
// return 'tsx';
// case 'html':
// return 'html';
// case 'css':
// return 'css';
default:
return 'txt';
}
}
export function getTemplate(type: string, language?: string): SandpackPredefinedTemplate {
return (
artifactTemplate[`${type}${(language?.length ?? 0) > 0 ? `-${language}` : ''}`] ?? 'react-ts'
);
}
export const sharedProps: Partial<SandpackProviderProps> = {
customSetup: {
dependencies: {
'lucide-react': '^0.394.0',
'react-router-dom': '^6.11.2',
'class-variance-authority': '^0.6.0',
clsx: '^1.2.1',
'date-fns': '^3.3.1',
'tailwind-merge': '^1.9.1',
'tailwindcss-animate': '^1.0.5',
recharts: '2.12.7',
'@radix-ui/react-accordion': '^1.1.2',
'@radix-ui/react-alert-dialog': '^1.0.2',
'@radix-ui/react-aspect-ratio': '^1.1.0',
'@radix-ui/react-avatar': '^1.1.0',
'@radix-ui/react-checkbox': '^1.0.3',
'@radix-ui/react-collapsible': '^1.0.3',
'@radix-ui/react-dialog': '^1.0.2',
'@radix-ui/react-dropdown-menu': '^2.1.1',
'@radix-ui/react-hover-card': '^1.0.5',
'@radix-ui/react-label': '^2.0.0',
'@radix-ui/react-menubar': '^1.1.1',
'@radix-ui/react-navigation-menu': '^1.2.0',
'@radix-ui/react-popover': '^1.0.7',
'@radix-ui/react-progress': '^1.1.0',
'@radix-ui/react-radio-group': '^1.1.3',
'@radix-ui/react-select': '^2.0.0',
'@radix-ui/react-separator': '^1.0.3',
'@radix-ui/react-slider': '^1.1.1',
'@radix-ui/react-switch': '^1.0.3',
'@radix-ui/react-tabs': '^1.0.3',
'@radix-ui/react-toast': '^1.1.5',
'@radix-ui/react-tooltip': '^1.0.6',
'@radix-ui/react-slot': '^1.1.0',
'@radix-ui/react-toggle': '^1.1.0',
'@radix-ui/react-toggle-group': '^1.1.0',
'embla-carousel-react': '^8.2.0',
'react-day-picker': '^9.0.8',
vaul: '^0.9.1',
},
},
} as const;
export const sharedOptions: SandpackProviderProps['options'] = {
externalResources: ['https://unpkg.com/@tailwindcss/ui/dist/tailwind-ui.min.css'],
};
export const sharedFiles = {
// '/lib/utils.ts': shadcnComponents.utils,
// '/components/ui/accordion.tsx': shadcnComponents.accordian,
// '/components/ui/alert-dialog.tsx': shadcnComponents.alertDialog,
// '/components/ui/alert.tsx': shadcnComponents.alert,
// '/components/ui/avatar.tsx': shadcnComponents.avatar,
// '/components/ui/badge.tsx': shadcnComponents.badge,
// '/components/ui/breadcrumb.tsx': shadcnComponents.breadcrumb,
// '/components/ui/button.tsx': shadcnComponents.button,
// '/components/ui/calendar.tsx': shadcnComponents.calendar,
// '/components/ui/card.tsx': shadcnComponents.card,
// '/components/ui/carousel.tsx': shadcnComponents.carousel,
// '/components/ui/checkbox.tsx': shadcnComponents.checkbox,
// '/components/ui/collapsible.tsx': shadcnComponents.collapsible,
// '/components/ui/dialog.tsx': shadcnComponents.dialog,
// '/components/ui/drawer.tsx': shadcnComponents.drawer,
// '/components/ui/dropdown-menu.tsx': shadcnComponents.dropdownMenu,
// '/components/ui/input.tsx': shadcnComponents.input,
// '/components/ui/label.tsx': shadcnComponents.label,
// '/components/ui/menubar.tsx': shadcnComponents.menuBar,
// '/components/ui/navigation-menu.tsx': shadcnComponents.navigationMenu,
// '/components/ui/pagination.tsx': shadcnComponents.pagination,
// '/components/ui/popover.tsx': shadcnComponents.popover,
// '/components/ui/progress.tsx': shadcnComponents.progress,
// '/components/ui/radio-group.tsx': shadcnComponents.radioGroup,
// '/components/ui/select.tsx': shadcnComponents.select,
// '/components/ui/separator.tsx': shadcnComponents.separator,
// '/components/ui/skeleton.tsx': shadcnComponents.skeleton,
// '/components/ui/slider.tsx': shadcnComponents.slider,
// '/components/ui/switch.tsx': shadcnComponents.switchComponent,
// '/components/ui/table.tsx': shadcnComponents.table,
// '/components/ui/tabs.tsx': shadcnComponents.tabs,
// '/components/ui/textarea.tsx': shadcnComponents.textarea,
// '/components/ui/toast.tsx': shadcnComponents.toast,
// '/components/ui/toaster.tsx': shadcnComponents.toaster,
// '/components/ui/toggle-group.tsx': shadcnComponents.toggleGroup,
// '/components/ui/toggle.tsx': shadcnComponents.toggle,
// '/components/ui/tooltip.tsx': shadcnComponents.tooltip,
// '/components/ui/use-toast.tsx': shadcnComponents.useToast,
'/public/index.html': dedent`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
`,
};
export function preprocessCodeArtifacts(text?: string): string {
if (typeof text !== 'string') {
return '';
}
// Remove <thinking> tags and their content
text = text.replace(/<thinking>[\s\S]*?<\/thinking>|<thinking>[\s\S]*/g, '');
// Process artifact headers
const regex = /(^|\n)(:::artifact[\s\S]*?(?:```[\s\S]*?```|$))/g;
return text.replace(regex, (match, newline, artifactBlock) => {
if (artifactBlock.includes('```') === true) {
// Keep artifact headers with code blocks (empty or not)
return newline + artifactBlock;
}
// Remove artifact headers without code blocks, but keep the newline
return newline;
});
}

View File

@@ -27,6 +27,12 @@ const codeFile = {
title: 'Code',
};
const artifact = {
paths: CodePaths,
fill: '#2D305C',
title: 'Code',
};
export const fileTypes = {
/* Category matches */
file: {
@@ -41,6 +47,7 @@ export const fileTypes = {
csv: spreadsheet,
pdf: textDocument,
'text/x-': codeFile,
artifact: artifact,
/* Exact matches */
// 'application/json':,

View File

@@ -0,0 +1,12 @@
export const name = 'Avatar';
export const importDocs = `
import { Avatar, AvatarFallback, AvatarImage } from "/components/ui/avatar";
`;
export const usageDocs = `
<Avatar>
<AvatarImage src="https://github.com/nutlope.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
`;

View File

@@ -0,0 +1,14 @@
export const name = 'Button';
export const importDocs = `
import { Button } from "/components/ui/button"
`;
export const usageDocs = `
<Button>A normal button</Button>
<Button variant='secondary'>Button</Button>
<Button variant='destructive'>Button</Button>
<Button variant='outline'>Button</Button>
<Button variant='ghost'>Button</Button>
<Button variant='link'>Button</Button>
`;

View File

@@ -0,0 +1,27 @@
export const name = 'Card';
export const importDocs = `
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "/components/ui/card"
`;
export const usageDocs = `
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent>
<p>Card Content</p>
</CardContent>
<CardFooter>
<p>Card Footer</p>
</CardFooter>
</Card>
`;

View File

@@ -0,0 +1,9 @@
export const name = 'Checkbox';
export const importDocs = `
import { Checkbox } from "/components/ui/checkbox"
`;
export const usageDocs = `
<Checkbox />
`;

View File

@@ -0,0 +1,23 @@
import * as Avatar from './avatar';
import * as Button from './button';
import * as Card from './card';
// import * as Checkbox from "./checkbox";
import * as Input from './input';
import * as Label from './label';
import * as RadioGroup from './radio-group';
import * as Select from './select';
import * as Textarea from './textarea';
const shadcnDocs = [
Avatar,
Button,
Card,
// Checkbox,
Input,
Label,
RadioGroup,
Select,
Textarea,
];
export default shadcnDocs;

View File

@@ -0,0 +1,9 @@
export const name = 'Input';
export const importDocs = `
import { Input } from "/components/ui/input"
`;
export const usageDocs = `
<Input />
`;

View File

@@ -0,0 +1,9 @@
export const name = 'Label';
export const importDocs = `
import { Label } from "/components/ui/label"
`;
export const usageDocs = `
<Label htmlFor="email">Your email address</Label>
`;

View File

@@ -0,0 +1,19 @@
export const name = 'RadioGroup';
export const importDocs = `
import { Label } from "/components/ui/label"
import { RadioGroup, RadioGroupItem } from "/components/ui/radio-group"
`;
export const usageDocs = `
<RadioGroup defaultValue="option-one">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option-one" id="option-one" />
<Label htmlFor="option-one">Option One</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option-two" id="option-two" />
<Label htmlFor="option-two">Option Two</Label>
</div>
</RadioGroup>
`;

View File

@@ -0,0 +1,24 @@
export const name = 'Select';
export const importDocs = `
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "/components/ui/select"
`;
export const usageDocs = `
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
`;

View File

@@ -0,0 +1,9 @@
export const name = 'Textarea';
export const importDocs = `
import { Textarea } from "/components/ui/textarea"
`;
export const usageDocs = `
<Textarea />
`;

4141
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -398,6 +398,8 @@ export const tConversationSchema = z.object({
max_tokens: coerceNumber.optional(),
/* Anthropic */
promptCache: z.boolean().optional(),
/* artifacts */
artifacts: z.string().optional(),
/* vision */
resendFiles: z.boolean().optional(),
imageDetail: eImageDetailSchema.optional(),
@@ -508,6 +510,7 @@ export const openAISchema = tConversationSchema
presence_penalty: true,
frequency_penalty: true,
resendFiles: true,
artifacts: true,
imageDetail: true,
stop: true,
iconURL: true,
@@ -569,6 +572,7 @@ export const googleSchema = tConversationSchema
examples: true,
temperature: true,
maxOutputTokens: true,
artifacts: true,
topP: true,
topK: true,
iconURL: true,
@@ -654,6 +658,7 @@ export const anthropicSchema = tConversationSchema
topK: true,
resendFiles: true,
promptCache: true,
artifacts: true,
iconURL: true,
greeting: true,
spec: true,
@@ -719,6 +724,7 @@ export const gptPluginsSchema = tConversationSchema
chatGptLabel: true,
promptPrefix: true,
temperature: true,
artifacts: true,
top_p: true,
presence_penalty: true,
frequency_penalty: true,
@@ -796,6 +802,7 @@ export const assistantSchema = tConversationSchema
model: true,
assistant_id: true,
instructions: true,
artifacts: true,
promptPrefix: true,
iconURL: true,
greeting: true,
@@ -827,6 +834,7 @@ export const compactAssistantSchema = tConversationSchema
assistant_id: true,
instructions: true,
promptPrefix: true,
artifacts: true,
iconURL: true,
greeting: true,
spec: true,
@@ -845,6 +853,7 @@ export const compactOpenAISchema = tConversationSchema
presence_penalty: true,
frequency_penalty: true,
resendFiles: true,
artifacts: true,
imageDetail: true,
stop: true,
iconURL: true,
@@ -886,6 +895,7 @@ export const compactGoogleSchema = tConversationSchema
examples: true,
temperature: true,
maxOutputTokens: true,
artifacts: true,
topP: true,
topK: true,
iconURL: true,
@@ -923,6 +933,7 @@ export const compactAnthropicSchema = tConversationSchema
topK: true,
resendFiles: true,
promptCache: true,
artifacts: true,
iconURL: true,
greeting: true,
spec: true,

View File

@@ -51,6 +51,7 @@ export type TPayload = Partial<TMessage> &
};
export type TSubmission = {
artifacts?: string;
plugin?: TResPlugin;
plugins?: TResPlugin[];
userMessage: TMessage;