mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Add "continue in your own agent" CTA to dashboard AI chats (#1778)
This commit is contained in:
parent
970c01998c
commit
67e9501801
@ -1,6 +1,7 @@
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button, useToast } from "@/components/ui";
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
validateComposerImageCount,
|
||||
@ -19,13 +20,14 @@ import {
|
||||
useMessage,
|
||||
useThreadRuntime,
|
||||
type PendingAttachment,
|
||||
type ThreadMessage,
|
||||
} from "@assistant-ui/react";
|
||||
import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle, XIcon } from "@phosphor-icons/react";
|
||||
import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, PlugsConnectedIcon, WarningCircle, XIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
} from "@hexclave/shared/dist/ai/image-limits";
|
||||
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react";
|
||||
|
||||
type AssistantContentComponents = ComponentProps<typeof MessagePrimitive.Content>["components"];
|
||||
const AssistantContentComponentsContext = createContext<AssistantContentComponents | undefined>(undefined);
|
||||
@ -68,7 +70,14 @@ export const Thread: FC<{
|
||||
/** Overrides for the assistant message content slots (Text / tools / etc.). */
|
||||
assistantContentComponents?: AssistantContentComponents,
|
||||
autoFocusComposer?: boolean,
|
||||
}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter, welcome, assistantContentComponents, autoFocusComposer = true }) => {
|
||||
/**
|
||||
* Shows a subtle line beneath the composer inviting the user to continue the
|
||||
* conversation in their own coding agent (via the Hexclave MCP server) or to
|
||||
* copy the chat transcript so they can paste it elsewhere. Enable this on the
|
||||
* general-purpose dashboard AI chats (command center, companion widget).
|
||||
*/
|
||||
agentEjectFooter?: boolean,
|
||||
}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter, welcome, assistantContentComponents, autoFocusComposer = true, agentEjectFooter = false }) => {
|
||||
return (
|
||||
<HideMessageActionsContext.Provider value={hideMessageActions}>
|
||||
<HasRunningStatusContext.Provider value={!!runningStatusMessages}>
|
||||
@ -111,6 +120,7 @@ export const Thread: FC<{
|
||||
<div className="sticky bottom-0 mt-2 flex w-full max-w-[var(--thread-max-width)] flex-col items-center justify-end pt-6 pb-3">
|
||||
<ThreadScrollToBottom />
|
||||
<Composer placeholder={composerPlaceholder} autoFocus={autoFocusComposer} />
|
||||
{agentEjectFooter && <AgentEjectFooter />}
|
||||
</div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
@ -547,6 +557,92 @@ const Composer: FC<{ placeholder?: ComposerPlaceholder, autoFocus?: boolean }> =
|
||||
);
|
||||
};
|
||||
|
||||
function serializeThreadToMarkdown(messages: readonly ThreadMessage[]): string {
|
||||
const blocks: string[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const text = (typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.map((part) => (part.type === "text" ? part.text : "")).join("")
|
||||
).trim();
|
||||
if (text.length === 0) continue;
|
||||
blocks.push(`## ${message.role === "user" ? "User" : "Assistant"}\n\n${text}`);
|
||||
}
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtle call-to-action beneath the composer that lets a user "eject" a chat
|
||||
* they started in the dashboard into their own coding agent — either by wiring
|
||||
* up the Hexclave MCP server (live docs + tools) or by copying the transcript
|
||||
* to paste wherever they like.
|
||||
*/
|
||||
const AgentEjectFooter: FC = () => {
|
||||
const threadRuntime = useThreadRuntime();
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [messageCount, setMessageCount] = useState(() => threadRuntime.getState().messages.length);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setMessageCount(threadRuntime.getState().messages.length);
|
||||
sync();
|
||||
return threadRuntime.subscribe(sync);
|
||||
}, [threadRuntime]);
|
||||
|
||||
const mcpDocsUrl = useMemo(() => {
|
||||
const base = (getPublicEnvVar("NEXT_PUBLIC_STACK_DOCS_BASE_URL") ?? "https://docs.hexclave.com").replace(/\/$/, "");
|
||||
return `${base}/guides/getting-started/ai-integration#option-3-mcp`;
|
||||
}, []);
|
||||
|
||||
const hasMessages = messageCount > 0;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const markdown = serializeThreadToMarkdown(threadRuntime.getState().messages);
|
||||
if (markdown.length === 0) {
|
||||
toast({ description: "No chat history to copy yet." });
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [threadRuntime, toast]);
|
||||
|
||||
const linkClass = "inline-flex items-center gap-1 font-medium underline decoration-foreground/20 underline-offset-2 transition-colors hover:transition-none hover:text-foreground hover:decoration-foreground/40";
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5 px-2 text-center text-[11px] leading-relaxed text-muted-foreground/50">
|
||||
<span>Want to continue in your own agent?</span>
|
||||
<a
|
||||
href={mcpDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(linkClass, "text-foreground/70")}
|
||||
>
|
||||
<PlugsConnectedIcon className="h-3 w-3" weight="bold" />
|
||||
Install our MCP server
|
||||
</a>
|
||||
<span className="text-muted-foreground/40">or</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAsynchronously(handleCopy())}
|
||||
disabled={!hasMessages}
|
||||
className={cn(
|
||||
hasMessages
|
||||
? cn(linkClass, "text-foreground/70")
|
||||
: "inline-flex cursor-not-allowed items-center gap-1 font-medium text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3 w-3 text-green-500" weight="bold" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy chat history"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerAction: FC = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -88,6 +88,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({
|
||||
composerAttachments
|
||||
attachmentAdapter={attachmentAdapter}
|
||||
autoFocusComposer={false}
|
||||
agentEjectFooter
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
|
||||
@ -527,6 +527,7 @@ function AIChatWidgetInner({
|
||||
welcome={<AskAiWelcome />}
|
||||
composerAttachments
|
||||
attachmentAdapter={attachmentAdapter}
|
||||
agentEjectFooter
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user