Compare commits
48 Commits
v0.7.5-rc2
...
tag-window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30aaf4e0d4 | ||
|
|
5aee9db6de | ||
|
|
6a8d4e43db | ||
|
|
e39a3bafec | ||
|
|
b1eb069931 | ||
|
|
7b20679463 | ||
|
|
efbed07569 | ||
|
|
37002edbba | ||
|
|
ee126a2350 | ||
|
|
e026fc7009 | ||
|
|
8f33fd5cc1 | ||
|
|
b16079915d | ||
|
|
336c7ad21a | ||
|
|
0dd0354a4e | ||
|
|
10f436521e | ||
|
|
ac352c9878 | ||
|
|
e3bcfc560d | ||
|
|
fa0032d91d | ||
|
|
8c1db607e5 | ||
|
|
bedc91adcd | ||
|
|
2ac9fd3aed | ||
|
|
dc5b597a64 | ||
|
|
48ddf4039e | ||
|
|
f2a516db02 | ||
|
|
f1fb8e991c | ||
|
|
15068fdfab | ||
|
|
4c810fa5db | ||
|
|
8ce70a41b7 | ||
|
|
068ec2fceb | ||
|
|
e2e42db24a | ||
|
|
5be1ffe490 | ||
|
|
85711d8ada | ||
|
|
5bf61c3cdf | ||
|
|
9abf941085 | ||
|
|
8e1807d02b | ||
|
|
66d5a1a368 | ||
|
|
c8d9af42e5 | ||
|
|
b3e3788261 | ||
|
|
0a54489842 | ||
|
|
6ee70acdc5 | ||
|
|
cd749a2bcb | ||
|
|
d86d4f5c17 | ||
|
|
fc6eb9f77f | ||
|
|
85df66265d | ||
|
|
801b0de49b | ||
|
|
24d74044e4 | ||
|
|
0340b4acb9 | ||
|
|
8c162842d6 |
162
api/app/clients/prompts/artifacts.js
Normal file
162
api/app/clients/prompts/artifacts.js
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
client/src/common/artifacts.ts
Normal file
15
client/src/common/artifacts.ts
Normal 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;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './artifacts';
|
||||
export * from './types';
|
||||
export * from './assistants-types';
|
||||
|
||||
104
client/src/components/Artifacts/Artifact.tsx
Normal file
104
client/src/components/Artifacts/Artifact.tsx
Normal 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} />;
|
||||
}
|
||||
44
client/src/components/Artifacts/ArtifactButton.tsx
Normal file
44
client/src/components/Artifacts/ArtifactButton.tsx
Normal 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;
|
||||
70
client/src/components/Artifacts/ArtifactPreview.tsx
Normal file
70
client/src/components/Artifacts/ArtifactPreview.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
201
client/src/components/Artifacts/Artifacts.tsx
Normal file
201
client/src/components/Artifacts/Artifacts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
client/src/components/Artifacts/Code.tsx
Normal file
119
client/src/components/Artifacts/Code.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
client/src/components/Artifacts/Mermaid.tsx
Normal file
33
client/src/components/Artifacts/Mermaid.tsx
Normal 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;
|
||||
230
client/src/components/Artifacts/example.tsx
Normal file
230
client/src/components/Artifacts/example.tsx
Normal 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}>÷</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}>×</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}>–</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>
|
||||
`,
|
||||
};
|
||||
37
client/src/components/Artifacts/useDebounceCodeBlock.ts
Normal file
37
client/src/components/Artifacts/useDebounceCodeBlock.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
116
client/src/hooks/Artifacts/useArtifacts.ts
Normal file
116
client/src/hooks/Artifacts/useArtifacts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
client/src/hooks/Artifacts/useAutoScroll.ts
Normal file
30
client/src/hooks/Artifacts/useAutoScroll.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
48
client/src/store/artifacts.ts
Normal file
48
client/src/store/artifacts.ts
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
94
client/src/utils/artifacts.spec.ts
Normal file
94
client/src/utils/artifacts.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
client/src/utils/artifacts.ts
Normal file
178
client/src/utils/artifacts.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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':,
|
||||
|
||||
12
client/src/utils/shadcn-docs/avatar.tsx
Normal file
12
client/src/utils/shadcn-docs/avatar.tsx
Normal 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>
|
||||
`;
|
||||
14
client/src/utils/shadcn-docs/button.tsx
Normal file
14
client/src/utils/shadcn-docs/button.tsx
Normal 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>
|
||||
`;
|
||||
27
client/src/utils/shadcn-docs/card.tsx
Normal file
27
client/src/utils/shadcn-docs/card.tsx
Normal 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>
|
||||
`;
|
||||
9
client/src/utils/shadcn-docs/checkbox.tsx
Normal file
9
client/src/utils/shadcn-docs/checkbox.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export const name = 'Checkbox';
|
||||
|
||||
export const importDocs = `
|
||||
import { Checkbox } from "/components/ui/checkbox"
|
||||
`;
|
||||
|
||||
export const usageDocs = `
|
||||
<Checkbox />
|
||||
`;
|
||||
23
client/src/utils/shadcn-docs/index.ts
Normal file
23
client/src/utils/shadcn-docs/index.ts
Normal 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;
|
||||
9
client/src/utils/shadcn-docs/input.tsx
Normal file
9
client/src/utils/shadcn-docs/input.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export const name = 'Input';
|
||||
|
||||
export const importDocs = `
|
||||
import { Input } from "/components/ui/input"
|
||||
`;
|
||||
|
||||
export const usageDocs = `
|
||||
<Input />
|
||||
`;
|
||||
9
client/src/utils/shadcn-docs/label.tsx
Normal file
9
client/src/utils/shadcn-docs/label.tsx
Normal 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>
|
||||
`;
|
||||
19
client/src/utils/shadcn-docs/radio-group.tsx
Normal file
19
client/src/utils/shadcn-docs/radio-group.tsx
Normal 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>
|
||||
`;
|
||||
24
client/src/utils/shadcn-docs/select.tsx
Normal file
24
client/src/utils/shadcn-docs/select.tsx
Normal 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>
|
||||
`;
|
||||
9
client/src/utils/shadcn-docs/textarea.tsx
Normal file
9
client/src/utils/shadcn-docs/textarea.tsx
Normal 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
4141
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -51,6 +51,7 @@ export type TPayload = Partial<TMessage> &
|
||||
};
|
||||
|
||||
export type TSubmission = {
|
||||
artifacts?: string;
|
||||
plugin?: TResPlugin;
|
||||
plugins?: TResPlugin[];
|
||||
userMessage: TMessage;
|
||||
|
||||
Reference in New Issue
Block a user