mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): Add markdown rendering and credentials setup to chat
This commit implements markdown rendering for chat messages and adds a comprehensive credentials setup flow for the chat interface. Key Changes: Chat Credentials Setup: - Add ChatCredentialsSetup component for managing multiple credentials - Support all credential types (API key, OAuth2, password, host-scoped) - Auto-detect existing credentials and mark as configured - Auto-complete when all credentials are set up - Reuse existing credential modals from AgentRunsView - Add comprehensive Storybook stories Markdown Rendering: - Create MarkdownContent atom component for chat messages - Support GitHub Flavored Markdown (tables, task lists, strikethrough) - Add explicit HTML sanitization (skipHtml) for XSS protection - Include comprehensive JSDoc documentation - Add 16 Storybook story variants covering edge cases - Apply to both ChatMessage and StreamingMessage components Message Flow Improvements: - Fix duplicate tool response messages in chat - Remove unnecessary backend refresh after stream ends - Local state now source of truth for messages during streaming - Update message types to support multiple credentials - Extract all missing credentials, not just first one Type Safety & Code Quality: - Replace all 'any' types with proper TypeScript interfaces - Add proper type definitions for ReactMarkdown component props - Fix color inconsistency (violet → purple) in StreamingMessage - Pass onSendMessage callback through MessageList to ChatMessage All changes follow frontend/CONTRIBUTING.md guidelines and pass TypeScript compilation checks.
This commit is contained in:
@@ -48,6 +48,7 @@ export function ChatContainer({
|
||||
messages={messages}
|
||||
streamingChunks={streamingChunks}
|
||||
isStreaming={isStreaming}
|
||||
onSendMessage={sendMessage}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -325,26 +325,29 @@ export function extractCredentialsNeeded(
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
|
||||
// If there are missing credentials, create the message
|
||||
// If there are missing credentials, create the message with ALL credentials
|
||||
if (missingCreds && Object.keys(missingCreds).length > 0) {
|
||||
// Get the first missing credential to show
|
||||
const firstCredKey = Object.keys(missingCreds)[0];
|
||||
const credInfo = missingCreds[firstCredKey];
|
||||
const agentName = (setupInfo?.agent_name as string) || "this agent";
|
||||
|
||||
return {
|
||||
type: "credentials_needed",
|
||||
// Map all missing credentials to the array format
|
||||
const credentials = Object.values(missingCreds).map((credInfo) => ({
|
||||
provider: (credInfo.provider as string) || "unknown",
|
||||
providerName:
|
||||
(credInfo.provider_name as string) ||
|
||||
(credInfo.provider as string) ||
|
||||
"Unknown Provider",
|
||||
credentialType: (credInfo.type as string) || "api_key",
|
||||
credentialType: (credInfo.type as "api_key" | "oauth2" | "user_password" | "host_scoped") || "api_key",
|
||||
title:
|
||||
(credInfo.title as string) ||
|
||||
(setupInfo?.agent_name as string) ||
|
||||
"this agent",
|
||||
message: `To run ${(setupInfo?.agent_name as string) || "this agent"}, you need to add ${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials.`,
|
||||
`${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
|
||||
scopes: credInfo.scopes as string[] | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
type: "credentials_needed",
|
||||
credentials,
|
||||
message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
|
||||
agentName,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,24 +301,9 @@ export function useChatContainer({
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
|
||||
// Refresh session data from backend, then clear local messages
|
||||
// The backend-persisted messages in initialMessages will replace our local ones
|
||||
onRefreshSession()
|
||||
.then(() => {
|
||||
// Clear local messages, but keep UI-only message types that aren't persisted by backend
|
||||
setMessages((prev) =>
|
||||
prev.filter((msg) =>
|
||||
// Keep UI-only message types
|
||||
msg.type === "credentials_needed" ||
|
||||
msg.type === "login_needed" ||
|
||||
msg.type === "no_results" ||
|
||||
msg.type === "agent_carousel"
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[Stream End] Failed to refresh session:", err);
|
||||
});
|
||||
// Messages are now in local state and will be displayed
|
||||
// No need to refresh from backend - local state is the source of truth
|
||||
console.log("[Stream End] Stream complete, messages in local state");
|
||||
} else if (chunk.type === "error") {
|
||||
const errorMessage =
|
||||
chunk.message || chunk.content || "An error occurred";
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ChatCredentialsSetup } from "./ChatCredentialsSetup";
|
||||
|
||||
const meta: Meta<typeof ChatCredentialsSetup> = {
|
||||
title: "Chat/ChatCredentialsSetup",
|
||||
component: ChatCredentialsSetup,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
onAllCredentialsComplete: { action: "all credentials complete" },
|
||||
onCancel: { action: "cancelled" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ChatCredentialsSetup>;
|
||||
|
||||
export const SingleAPIKey: Story = {
|
||||
args: {
|
||||
credentials: [
|
||||
{
|
||||
provider: "openai",
|
||||
providerName: "OpenAI",
|
||||
credentialType: "api_key",
|
||||
title: "OpenAI API",
|
||||
},
|
||||
],
|
||||
agentName: "GPT Assistant",
|
||||
message: "To run GPT Assistant, you need to add credentials.",
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleOAuth: Story = {
|
||||
args: {
|
||||
credentials: [
|
||||
{
|
||||
provider: "github",
|
||||
providerName: "GitHub",
|
||||
credentialType: "oauth2",
|
||||
title: "GitHub Integration",
|
||||
scopes: ["repo", "read:user"],
|
||||
},
|
||||
],
|
||||
agentName: "GitHub Agent",
|
||||
message: "To run GitHub Agent, you need to add credentials.",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleCredentials: Story = {
|
||||
args: {
|
||||
credentials: [
|
||||
{
|
||||
provider: "github",
|
||||
providerName: "GitHub",
|
||||
credentialType: "oauth2",
|
||||
title: "GitHub Integration",
|
||||
scopes: ["repo", "read:user"],
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
providerName: "OpenAI",
|
||||
credentialType: "api_key",
|
||||
title: "OpenAI API",
|
||||
},
|
||||
{
|
||||
provider: "notion",
|
||||
providerName: "Notion",
|
||||
credentialType: "oauth2",
|
||||
title: "Notion Integration",
|
||||
},
|
||||
],
|
||||
agentName: "Multi-Service Agent",
|
||||
message: "To run Multi-Service Agent, you need to add 3 credentials.",
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedCredentialTypes: Story = {
|
||||
args: {
|
||||
credentials: [
|
||||
{
|
||||
provider: "openai",
|
||||
providerName: "OpenAI",
|
||||
credentialType: "api_key",
|
||||
title: "OpenAI API",
|
||||
},
|
||||
{
|
||||
provider: "github",
|
||||
providerName: "GitHub",
|
||||
credentialType: "oauth2",
|
||||
title: "GitHub Integration",
|
||||
scopes: ["repo"],
|
||||
},
|
||||
{
|
||||
provider: "database",
|
||||
providerName: "Database",
|
||||
credentialType: "user_password",
|
||||
title: "Database Connection",
|
||||
},
|
||||
{
|
||||
provider: "custom_api",
|
||||
providerName: "Custom API",
|
||||
credentialType: "host_scoped",
|
||||
title: "Custom API Headers",
|
||||
},
|
||||
],
|
||||
agentName: "Full Stack Agent",
|
||||
message: "To run Full Stack Agent, you need to add 4 credentials.",
|
||||
},
|
||||
};
|
||||
|
||||
export const LongAgentName: Story = {
|
||||
args: {
|
||||
credentials: [
|
||||
{
|
||||
provider: "openai",
|
||||
providerName: "OpenAI",
|
||||
credentialType: "api_key",
|
||||
title: "OpenAI API",
|
||||
},
|
||||
],
|
||||
agentName:
|
||||
"Super Complex Multi-Step Data Processing and Analysis Agent with Machine Learning",
|
||||
message:
|
||||
"To run Super Complex Multi-Step Data Processing and Analysis Agent with Machine Learning, you need to add credentials.",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect } from "react";
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Key, Check, Warning } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
|
||||
import { APIKeyCredentialsModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/APIKeyCredentialsModal/APIKeyCredentialsModal";
|
||||
import { OAuthFlowWaitingModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/OAuthWaitingModal/OAuthWaitingModal";
|
||||
import { PasswordCredentialsModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/PasswordCredentialsModal/PasswordCredentialsModal";
|
||||
import { HostScopedCredentialsModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/HotScopedCredentialsModal/HotScopedCredentialsModal";
|
||||
|
||||
export interface CredentialInfo {
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
title: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
credentials: CredentialInfo[];
|
||||
agentName?: string;
|
||||
message: string;
|
||||
onAllCredentialsComplete: () => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatCredentialsSetup({
|
||||
credentials,
|
||||
agentName,
|
||||
message,
|
||||
onAllCredentialsComplete,
|
||||
onCancel,
|
||||
className,
|
||||
}: Props) {
|
||||
const {
|
||||
credentialStatuses,
|
||||
isAllComplete,
|
||||
activeModal,
|
||||
handleSetupClick,
|
||||
handleModalClose,
|
||||
handleCredentialCreated,
|
||||
} = useChatCredentialsSetup(credentials);
|
||||
|
||||
// Auto-call completion when all credentials are configured
|
||||
useEffect(
|
||||
function autoCompleteWhenReady() {
|
||||
if (isAllComplete) {
|
||||
onAllCredentialsComplete();
|
||||
}
|
||||
},
|
||||
[isAllComplete, onAllCredentialsComplete]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={cn(
|
||||
"mx-4 my-2 overflow-hidden border-orange-200 bg-orange-50 dark:border-orange-900 dark:bg-orange-950",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4 p-6">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-500">
|
||||
<Key size={24} weight="bold" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Text
|
||||
variant="h3"
|
||||
className="mb-2 text-orange-900 dark:text-orange-100"
|
||||
>
|
||||
Credentials Required
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
className="mb-4 text-orange-700 dark:text-orange-300"
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
<div className="space-y-3">
|
||||
{credentials.map((cred, index) => (
|
||||
<CredentialRow
|
||||
key={`${cred.provider}-${index}`}
|
||||
credential={cred}
|
||||
status={credentialStatuses[index]}
|
||||
onSetup={() => handleSetupClick(index, cred)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-orange-200 px-6 py-4 dark:border-orange-900">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Modals - reuse existing components */}
|
||||
{activeModal?.type === "api_key" && activeModal.schema && (
|
||||
<APIKeyCredentialsModal
|
||||
schema={activeModal.schema}
|
||||
open={true}
|
||||
onClose={handleModalClose}
|
||||
onCredentialsCreate={handleCredentialCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "oauth2" && (
|
||||
<OAuthFlowWaitingModal
|
||||
open={true}
|
||||
onClose={handleModalClose}
|
||||
providerName={activeModal.providerName || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "user_password" && activeModal.schema && (
|
||||
<PasswordCredentialsModal
|
||||
schema={activeModal.schema}
|
||||
open={true}
|
||||
onClose={handleModalClose}
|
||||
onCredentialsCreate={handleCredentialCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "host_scoped" && activeModal.schema && (
|
||||
<HostScopedCredentialsModal
|
||||
schema={activeModal.schema}
|
||||
open={true}
|
||||
onClose={handleModalClose}
|
||||
onCredentialsCreate={handleCredentialCreated}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialRowProps {
|
||||
credential: CredentialInfo;
|
||||
status?: {
|
||||
isConfigured: boolean;
|
||||
credentialId?: string;
|
||||
};
|
||||
onSetup: () => void;
|
||||
}
|
||||
|
||||
function CredentialRow({ credential, status, onSetup }: CredentialRowProps) {
|
||||
const isConfigured = status?.isConfigured || false;
|
||||
|
||||
function getCredentialTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case "api_key":
|
||||
return "API Key";
|
||||
case "oauth2":
|
||||
return "OAuth";
|
||||
case "user_password":
|
||||
return "Username & Password";
|
||||
case "host_scoped":
|
||||
return "Custom Headers";
|
||||
default:
|
||||
return "Credentials";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-orange-200 bg-white p-3 dark:border-orange-800 dark:bg-orange-900/20">
|
||||
<div className="flex items-center gap-3">
|
||||
{isConfigured ? (
|
||||
<Check size={20} className="text-green-500" weight="bold" />
|
||||
) : (
|
||||
<Warning size={20} className="text-orange-500" weight="bold" />
|
||||
)}
|
||||
<div>
|
||||
<Text variant="body" className="font-semibold text-orange-900 dark:text-orange-100">
|
||||
{credential.providerName}
|
||||
</Text>
|
||||
<Text variant="small" className="text-orange-700 dark:text-orange-300">
|
||||
{getCredentialTypeLabel(credential.credentialType)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isConfigured && (
|
||||
<Button size="small" onClick={onSetup} variant="primary">
|
||||
Setup
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect, useMemo, useContext } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { CredentialInfo } from "./ChatCredentialsSetup";
|
||||
import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api";
|
||||
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
|
||||
|
||||
interface CredentialStatus {
|
||||
isConfigured: boolean;
|
||||
credentialId?: string;
|
||||
}
|
||||
|
||||
interface ActiveModal {
|
||||
index: number;
|
||||
type: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
provider: string;
|
||||
providerName: string;
|
||||
schema?: BlockIOCredentialsSubSchema;
|
||||
}
|
||||
|
||||
export function useChatCredentialsSetup(credentials: CredentialInfo[]) {
|
||||
const allProviders = useContext(CredentialsProvidersContext);
|
||||
const [credentialStatuses, setCredentialStatuses] = useState<
|
||||
CredentialStatus[]
|
||||
>([]);
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal | null>(null);
|
||||
|
||||
// Check existing credentials on mount
|
||||
useEffect(
|
||||
function checkExistingCredentials() {
|
||||
const statuses = credentials.map((cred) => {
|
||||
const provider = allProviders?.[cred.provider];
|
||||
const hasSavedCredentials =
|
||||
provider && provider.savedCredentials && provider.savedCredentials.length > 0;
|
||||
|
||||
return {
|
||||
isConfigured: hasSavedCredentials || false,
|
||||
credentialId: hasSavedCredentials ? provider.savedCredentials[0].id : undefined,
|
||||
};
|
||||
});
|
||||
setCredentialStatuses(statuses);
|
||||
},
|
||||
[credentials, allProviders]
|
||||
);
|
||||
|
||||
const isAllComplete = useMemo(
|
||||
function checkAllComplete() {
|
||||
if (credentialStatuses.length === 0) return false;
|
||||
return credentialStatuses.every((status) => status.isConfigured);
|
||||
},
|
||||
[credentialStatuses]
|
||||
);
|
||||
|
||||
function handleSetupClick(index: number, credential: CredentialInfo) {
|
||||
const provider = allProviders?.[credential.provider];
|
||||
|
||||
if (!provider) {
|
||||
toast.error("Provider not found", {
|
||||
description: `Unable to find configuration for ${credential.providerName}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a minimal schema for the modal
|
||||
const schema: BlockIOCredentialsSubSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
credentials_provider: [credential.provider],
|
||||
credentials_types: [credential.credentialType],
|
||||
credentials_scopes: credential.scopes,
|
||||
discriminator: undefined,
|
||||
discriminator_mapping: undefined,
|
||||
discriminator_values: undefined,
|
||||
};
|
||||
|
||||
setActiveModal({
|
||||
index,
|
||||
type: credential.credentialType,
|
||||
provider: credential.provider,
|
||||
providerName: credential.providerName,
|
||||
schema,
|
||||
});
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
setActiveModal(null);
|
||||
}
|
||||
|
||||
function handleCredentialCreated(credentialMeta: CredentialsMetaInput) {
|
||||
if (activeModal) {
|
||||
// Mark credential as complete
|
||||
setCredentialStatuses((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[activeModal.index] = {
|
||||
isConfigured: true,
|
||||
credentialId: credentialMeta.id,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
|
||||
toast.success("Credential added successfully", {
|
||||
description: `${activeModal.providerName} credentials have been configured`,
|
||||
});
|
||||
setActiveModal(null);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
credentialStatuses,
|
||||
isAllComplete,
|
||||
activeModal,
|
||||
handleSetupClick,
|
||||
handleModalClose,
|
||||
handleCredentialCreated,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/MarkdownContent",
|
||||
component: MarkdownContent,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof MarkdownContent>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const BasicText: Story = {
|
||||
args: {
|
||||
content: "This is a simple paragraph with **bold text** and *italic text*.",
|
||||
},
|
||||
};
|
||||
|
||||
export const InlineCode: Story = {
|
||||
args: {
|
||||
content:
|
||||
"Use the `useState` hook to manage state in React components. You can also use `useEffect` for side effects.",
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlock: Story = {
|
||||
args: {
|
||||
content: `Here's a code example:
|
||||
|
||||
\`\`\`typescript
|
||||
function greet(name: string): string {
|
||||
return \`Hello, \${name}!\`;
|
||||
}
|
||||
|
||||
const message = greet("World");
|
||||
console.log(message);
|
||||
\`\`\`
|
||||
|
||||
This is a TypeScript function that returns a greeting.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const Links: Story = {
|
||||
args: {
|
||||
content: `Check out these resources:
|
||||
- [React Documentation](https://react.dev)
|
||||
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
|
||||
All links open in new tabs for your convenience.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const UnorderedList: Story = {
|
||||
args: {
|
||||
content: `Shopping list:
|
||||
- Apples
|
||||
- Bananas
|
||||
- Oranges
|
||||
- Grapes
|
||||
- Strawberries`,
|
||||
},
|
||||
};
|
||||
|
||||
export const OrderedList: Story = {
|
||||
args: {
|
||||
content: `Steps to deploy:
|
||||
1. Run tests locally
|
||||
2. Create a pull request
|
||||
3. Wait for CI to pass
|
||||
4. Get code review approval
|
||||
5. Merge to main
|
||||
6. Deploy to production`,
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskList: Story = {
|
||||
args: {
|
||||
content: `Project tasks:
|
||||
- [x] Set up project structure
|
||||
- [x] Implement authentication
|
||||
- [ ] Add user dashboard
|
||||
- [ ] Create admin panel
|
||||
- [ ] Write documentation`,
|
||||
},
|
||||
};
|
||||
|
||||
export const Blockquote: Story = {
|
||||
args: {
|
||||
content: `As Einstein said:
|
||||
|
||||
> Imagination is more important than knowledge. Knowledge is limited. Imagination encircles the world.
|
||||
|
||||
This quote reminds us to think creatively.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const Table: Story = {
|
||||
args: {
|
||||
content: `Here's a comparison table:
|
||||
|
||||
| Feature | Basic | Pro | Enterprise |
|
||||
|---------|-------|-----|------------|
|
||||
| Users | 5 | 50 | Unlimited |
|
||||
| Storage | 10GB | 100GB | 1TB |
|
||||
| Support | Email | Priority | 24/7 Phone |
|
||||
| Price | $9/mo | $29/mo | Custom |`,
|
||||
},
|
||||
};
|
||||
|
||||
export const Headings: Story = {
|
||||
args: {
|
||||
content: `# Heading 1
|
||||
This is the largest heading.
|
||||
|
||||
## Heading 2
|
||||
A bit smaller.
|
||||
|
||||
### Heading 3
|
||||
Even smaller.
|
||||
|
||||
#### Heading 4
|
||||
Getting smaller still.
|
||||
|
||||
##### Heading 5
|
||||
Almost the smallest.
|
||||
|
||||
###### Heading 6
|
||||
The smallest heading.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const StrikethroughAndFormatting: Story = {
|
||||
args: {
|
||||
content: `Text formatting options:
|
||||
- **Bold text** is important
|
||||
- *Italic text* is emphasized
|
||||
- ~~Strikethrough~~ text is deleted
|
||||
- ***Bold and italic*** is very important
|
||||
- **Bold with *nested italic***`,
|
||||
},
|
||||
};
|
||||
|
||||
export const HorizontalRule: Story = {
|
||||
args: {
|
||||
content: `Section One
|
||||
|
||||
---
|
||||
|
||||
Section Two
|
||||
|
||||
---
|
||||
|
||||
Section Three`,
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedContent: Story = {
|
||||
args: {
|
||||
content: `# Chat Message Example
|
||||
|
||||
I found **three solutions** to your problem:
|
||||
|
||||
## 1. Using the API
|
||||
|
||||
You can call the endpoint like this:
|
||||
|
||||
\`\`\`typescript
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': \`Bearer \${token}\` }
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
## 2. Using the CLI
|
||||
|
||||
Alternatively, use the command line:
|
||||
|
||||
\`\`\`bash
|
||||
cli users list --format json
|
||||
\`\`\`
|
||||
|
||||
## 3. Manual approach
|
||||
|
||||
If you prefer, you can:
|
||||
1. Open the dashboard
|
||||
2. Navigate to *Users* section
|
||||
3. Click **Export**
|
||||
4. Choose JSON format
|
||||
|
||||
> **Note**: The API approach is recommended for automation.
|
||||
|
||||
For more information, check out the [documentation](https://docs.example.com).`,
|
||||
},
|
||||
};
|
||||
|
||||
export const XSSAttempt: Story = {
|
||||
args: {
|
||||
content: `# Security Test
|
||||
|
||||
This content attempts XSS attacks that should be escaped:
|
||||
|
||||
<script>alert('XSS')</script>
|
||||
|
||||
<img src="x" onerror="alert('XSS')">
|
||||
|
||||
<a href="javascript:alert('XSS')">Click me</a>
|
||||
|
||||
<style>body { background: red; }</style>
|
||||
|
||||
All of these should render as plain text, not execute.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const MalformedMarkdown: Story = {
|
||||
args: {
|
||||
content: `# Unclosed Heading
|
||||
|
||||
**Bold without closing
|
||||
|
||||
\`\`\`
|
||||
Code block without closing language tag
|
||||
|
||||
[Link with no URL]
|
||||
|
||||
![Image with no src]
|
||||
|
||||
**Nested *formatting without** proper closing*
|
||||
|
||||
| Table | with |
|
||||
| mismatched | columns | extra |`,
|
||||
},
|
||||
};
|
||||
|
||||
export const UnicodeAndEmoji: Story = {
|
||||
args: {
|
||||
content: `# Unicode Support
|
||||
|
||||
## Emojis
|
||||
🎉 🚀 💡 ✨ 🔥 👍 ❤️ 🎯 📊 🌟
|
||||
|
||||
## Special Characters
|
||||
→ ← ↑ ↓ © ® ™ € £ ¥ § ¶ † ‡
|
||||
|
||||
## Other Languages
|
||||
你好世界 (Chinese)
|
||||
مرحبا بالعالم (Arabic)
|
||||
Привет мир (Russian)
|
||||
हैलो वर्ल्ड (Hindi)
|
||||
|
||||
All characters should render correctly.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongCodeBlock: Story = {
|
||||
args: {
|
||||
content: `Here's a longer code example that tests overflow:
|
||||
|
||||
\`\`\`typescript
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
roles: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function processUsers(users: User[]): Map<string, User> {
|
||||
return users.reduce((acc, user) => {
|
||||
acc.set(user.id, user);
|
||||
return acc;
|
||||
}, new Map<string, User>());
|
||||
}
|
||||
|
||||
const users: User[] = [
|
||||
{ id: '1', name: 'Alice', email: 'alice@example.com', createdAt: new Date(), updatedAt: new Date(), roles: ['admin'], metadata: {} },
|
||||
{ id: '2', name: 'Bob', email: 'bob@example.com', createdAt: new Date(), updatedAt: new Date(), roles: ['user'], metadata: {} },
|
||||
];
|
||||
|
||||
const userMap = processUsers(users);
|
||||
console.log(userMap);
|
||||
\`\`\`
|
||||
|
||||
The code block should scroll horizontally if needed.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedStructures: Story = {
|
||||
args: {
|
||||
content: `# Nested Structures
|
||||
|
||||
## Lists within Blockquotes
|
||||
|
||||
> Here's a quote with a list:
|
||||
> - First item
|
||||
> - Second item
|
||||
> - Third item
|
||||
|
||||
## Blockquotes within Lists
|
||||
|
||||
- Regular list item
|
||||
- List item with quote:
|
||||
> This is a nested quote
|
||||
- Another regular item
|
||||
|
||||
## Code in Lists
|
||||
|
||||
1. First step: Install dependencies
|
||||
\`\`\`bash
|
||||
npm install
|
||||
\`\`\`
|
||||
2. Second step: Run the server
|
||||
\`\`\`bash
|
||||
npm start
|
||||
\`\`\`
|
||||
3. Third step: Open browser`,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Type definitions for ReactMarkdown component props
|
||||
interface CodeProps extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ListProps extends React.HTMLAttributes<HTMLUListElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer for chat messages.
|
||||
*
|
||||
* Security: Uses ReactMarkdown v9+ which automatically escapes HTML by default.
|
||||
* HTML tags in markdown content will be rendered as text, not executed.
|
||||
*
|
||||
* Supports GitHub Flavored Markdown:
|
||||
* - Tables
|
||||
* - Task lists with checkboxes
|
||||
* - Strikethrough
|
||||
* - Autolinks
|
||||
*
|
||||
* @param content - Raw markdown string (user-generated content is safe)
|
||||
* @param className - Additional Tailwind classes to apply to the container
|
||||
*
|
||||
* @remarks
|
||||
* For full-featured markdown with math/syntax highlighting, see MarkdownRenderer
|
||||
* in OutputRenderers.
|
||||
*/
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={cn("markdown-content", className)}>
|
||||
<ReactMarkdown
|
||||
// Security: skipHtml is true by default in react-markdown v9+
|
||||
// This prevents XSS attacks by escaping any HTML in the markdown
|
||||
skipHtml={true}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Inline code
|
||||
code: ({ children, className, ...props }: CodeProps) => {
|
||||
const isInline = !className?.includes("language-");
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-sm text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// Block code
|
||||
return (
|
||||
<code
|
||||
className="font-mono text-sm text-zinc-100 dark:text-zinc-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
// Code blocks
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
className="my-2 overflow-x-auto rounded-md bg-zinc-900 p-3 dark:bg-zinc-950"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Links
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-600 underline decoration-1 underline-offset-2 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Bold
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="font-semibold" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
// Italic
|
||||
em: ({ children, ...props }) => (
|
||||
<em className="italic" {...props}>
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
// Strikethrough
|
||||
del: ({ children, ...props }) => (
|
||||
<del className="line-through opacity-70" {...props}>
|
||||
{children}
|
||||
</del>
|
||||
),
|
||||
// Lists
|
||||
ul: ({ children, ...props }: ListProps) => (
|
||||
<ul
|
||||
className={cn(
|
||||
"my-2 space-y-1 pl-6",
|
||||
props.className?.includes("contains-task-list")
|
||||
? "list-none pl-0"
|
||||
: "list-disc",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-6" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }: ListItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
props.className?.includes("task-list-item")
|
||||
? "flex items-start"
|
||||
: "",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
// Task list checkboxes
|
||||
input: ({ ...props }: InputProps) => {
|
||||
if (props.type === "checkbox") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2 h-4 w-4 rounded border-zinc-300 text-purple-600 focus:ring-purple-500 disabled:cursor-not-allowed disabled:opacity-70 dark:border-zinc-600"
|
||||
disabled
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <input {...props} />;
|
||||
},
|
||||
// Blockquotes
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="my-2 border-l-4 border-zinc-300 pl-3 italic text-zinc-700 dark:border-zinc-600 dark:text-zinc-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// Headings (smaller sizes for chat)
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1
|
||||
className="my-2 text-xl font-bold text-zinc-900 dark:text-zinc-100"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2
|
||||
className="my-2 text-lg font-semibold text-zinc-800 dark:text-zinc-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3
|
||||
className="my-1 text-base font-semibold text-zinc-800 dark:text-zinc-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4
|
||||
className="my-1 text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children, ...props }) => (
|
||||
<h5
|
||||
className="my-1 text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<h6
|
||||
className="my-1 text-xs font-medium text-zinc-600 dark:text-zinc-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
// Paragraphs
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="my-2 leading-relaxed" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
// Horizontal rule
|
||||
hr: ({ ...props }) => (
|
||||
<hr className="my-3 border-zinc-300 dark:border-zinc-700" {...props} />
|
||||
),
|
||||
// Tables
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full divide-y divide-zinc-200 rounded border border-zinc-200 dark:divide-zinc-700 dark:border-zinc-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="bg-zinc-50 px-3 py-2 text-left text-xs font-semibold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="border-t border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { cn } from "@/lib/utils";
|
||||
import { Robot, User } from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MessageBubble } from "@/components/atoms/MessageBubble/MessageBubble";
|
||||
import { MarkdownContent } from "@/components/atoms/MarkdownContent/MarkdownContent";
|
||||
import { ToolCallMessage } from "@/components/molecules/ToolCallMessage/ToolCallMessage";
|
||||
import { ToolResponseMessage } from "@/components/molecules/ToolResponseMessage/ToolResponseMessage";
|
||||
import { LoginPrompt } from "@/components/molecules/LoginPrompt/LoginPrompt";
|
||||
import { CredentialsNeededPrompt } from "@/components/molecules/CredentialsNeededPrompt/CredentialsNeededPrompt";
|
||||
import { ChatCredentialsSetup } from "@/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import { NoResultsMessage } from "@/components/molecules/NoResultsMessage/NoResultsMessage";
|
||||
import { AgentCarouselMessage } from "@/components/molecules/AgentCarouselMessage/AgentCarouselMessage";
|
||||
import { ExecutionStartedMessage } from "@/components/molecules/ExecutionStartedMessage/ExecutionStartedMessage";
|
||||
@@ -16,6 +17,7 @@ export interface ChatMessageProps {
|
||||
className?: string;
|
||||
onDismissLogin?: () => void;
|
||||
onDismissCredentials?: () => void;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
@@ -23,6 +25,7 @@ export function ChatMessage({
|
||||
className,
|
||||
onDismissLogin,
|
||||
onDismissCredentials,
|
||||
onSendMessage,
|
||||
}: ChatMessageProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
@@ -52,10 +55,15 @@ export function ChatMessage({
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetupCredentials() {
|
||||
// For now, redirect to integrations page
|
||||
// TODO: Deep link to specific provider when backend provides agent/block context
|
||||
router.push("/integrations");
|
||||
function handleAllCredentialsComplete() {
|
||||
// Send a user message saying credentials are configured
|
||||
if (onSendMessage) {
|
||||
onSendMessage("Credentials have been configured. Ready to proceed.");
|
||||
}
|
||||
// Optionally dismiss the credentials prompt
|
||||
if (onDismissCredentials) {
|
||||
onDismissCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelCredentials() {
|
||||
@@ -68,13 +76,11 @@ export function ChatMessage({
|
||||
// Render credentials needed messages
|
||||
if (isCredentialsNeeded && message.type === "credentials_needed") {
|
||||
return (
|
||||
<CredentialsNeededPrompt
|
||||
provider={message.provider}
|
||||
providerName={message.providerName}
|
||||
credentialType={message.credentialType}
|
||||
title={message.title}
|
||||
<ChatCredentialsSetup
|
||||
credentials={message.credentials}
|
||||
agentName={message.agentName}
|
||||
message={message.message}
|
||||
onSetupCredentials={handleSetupCredentials}
|
||||
onAllCredentialsComplete={handleAllCredentialsComplete}
|
||||
onCancel={handleCancelCredentials}
|
||||
className={className}
|
||||
/>
|
||||
@@ -184,7 +190,7 @@ export function ChatMessage({
|
||||
{/* Message Content */}
|
||||
<div className={cn("flex max-w-[70%] flex-col", isUser && "items-end")}>
|
||||
<MessageBubble variant={isUser ? "user" : "assistant"}>
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
<MarkdownContent content={message.content} />
|
||||
</MessageBubble>
|
||||
|
||||
{/* Timestamp */}
|
||||
|
||||
@@ -31,12 +31,15 @@ export type ChatMessageData =
|
||||
}
|
||||
| {
|
||||
type: "credentials_needed";
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialType: string;
|
||||
title: string;
|
||||
credentials: Array<{
|
||||
provider: string;
|
||||
providerName: string;
|
||||
credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
|
||||
title: string;
|
||||
scopes?: string[];
|
||||
}>;
|
||||
message: string;
|
||||
scopes?: string[];
|
||||
agentName?: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -239,12 +239,17 @@ export const WithCredentialsPrompt: Story = {
|
||||
},
|
||||
{
|
||||
type: "credentials_needed" as const,
|
||||
provider: "github",
|
||||
providerName: "GitHub",
|
||||
credentialType: "oauth2",
|
||||
title: "GitHub Integration Agent",
|
||||
credentials: [
|
||||
{
|
||||
provider: "github",
|
||||
providerName: "GitHub",
|
||||
credentialType: "oauth2" as const,
|
||||
title: "GitHub Integration",
|
||||
},
|
||||
],
|
||||
agentName: "GitHub Integration Agent",
|
||||
message:
|
||||
"To run the GitHub Integration Agent, you need to add GitHub credentials.",
|
||||
"To run GitHub Integration Agent, you need to add credentials.",
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 1000),
|
||||
},
|
||||
],
|
||||
@@ -413,12 +418,17 @@ export const MixedConversation: Story = {
|
||||
},
|
||||
{
|
||||
type: "credentials_needed" as const,
|
||||
provider: "gmail",
|
||||
providerName: "Gmail",
|
||||
credentialType: "oauth2",
|
||||
title: "Email Automation",
|
||||
credentials: [
|
||||
{
|
||||
provider: "gmail",
|
||||
providerName: "Gmail",
|
||||
credentialType: "oauth2" as const,
|
||||
title: "Gmail Integration",
|
||||
},
|
||||
],
|
||||
agentName: "Email Automation",
|
||||
message:
|
||||
"To run the Email Automation agent, you need to add Gmail credentials.",
|
||||
"To run Email Automation, you need to add credentials.",
|
||||
timestamp: new Date(Date.now() - 8 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface MessageListProps {
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
onStreamComplete?: () => void;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
@@ -18,6 +19,7 @@ export function MessageList({
|
||||
isStreaming = false,
|
||||
className,
|
||||
onStreamComplete,
|
||||
onSendMessage,
|
||||
}: MessageListProps) {
|
||||
const { messagesEndRef, messagesContainerRef } = useMessageList({
|
||||
messageCount: messages.length,
|
||||
@@ -36,7 +38,7 @@ export function MessageList({
|
||||
<div className="space-y-0">
|
||||
{/* Render all persisted messages */}
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessage key={index} message={message} />
|
||||
<ChatMessage key={index} message={message} onSendMessage={onSendMessage} />
|
||||
))}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import { MessageBubble } from "@/components/atoms/MessageBubble/MessageBubble";
|
||||
import { MarkdownContent } from "@/components/atoms/MarkdownContent/MarkdownContent";
|
||||
import { useStreamingMessage } from "./useStreamingMessage";
|
||||
|
||||
export interface StreamingMessageProps {
|
||||
@@ -20,7 +21,7 @@ export function StreamingMessage({
|
||||
<div className={cn("flex gap-3 px-4 py-4", className)}>
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-600 dark:bg-violet-500">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-600 dark:bg-purple-500">
|
||||
<Robot className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +29,7 @@ export function StreamingMessage({
|
||||
{/* Message Content */}
|
||||
<div className="flex max-w-[70%] flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<div className="whitespace-pre-wrap">{displayText}</div>
|
||||
<MarkdownContent content={displayText} />
|
||||
</MessageBubble>
|
||||
|
||||
{/* Timestamp */}
|
||||
|
||||
Reference in New Issue
Block a user