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:
Swifty
2025-11-04 10:17:54 +01:00
parent 606c92f8d0
commit 3e68615b33
13 changed files with 1098 additions and 58 deletions

View File

@@ -48,6 +48,7 @@ export function ChatContainer({
messages={messages}
streamingChunks={streamingChunks}
isStreaming={isStreaming}
onSendMessage={sendMessage}
className="flex-1"
/>
)}

View File

@@ -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(),
};
}

View File

@@ -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";

View File

@@ -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.",
},
};

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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`,
},
};

View File

@@ -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>
);
}

View File

@@ -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 */}

View File

@@ -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;
}
| {

View File

@@ -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),
},
{

View File

@@ -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 */}

View File

@@ -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 */}