Compare commits

...

4 Commits

5 changed files with 118 additions and 26 deletions

View File

@ -333,8 +333,7 @@ export default function ChatPage() {
)} )}
> >
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
{!showWelcomeStyle && <MessageList
<MessageList
className={cn( className={cn(
"size-full", "size-full",
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]", (!showWelcomeStyle || hasSubmitted) && "pt-[58px]",
@ -347,8 +346,10 @@ export default function ChatPage() {
: thread.messages.slice(historyCutoff) : thread.messages.slice(historyCutoff)
} }
paddingBottom={todoListCollapsed ? 160 : 280} paddingBottom={todoListCollapsed ? 160 : 280}
// !showWelcomeStyle || hasSubmitted
showScrollToBottomButton={!showWelcomeStyle}
scrollButtonClassName="bottom-[112px]"
/> />
}
</div> </div>
</main> </main>
</div> </div>

View File

@ -84,7 +84,7 @@ export const ConversationScrollButton = ({
!isAtBottom && ( !isAtBottom && (
<Button <Button
className={cn( className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full", "absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full",
className, className,
)} )}
onClick={handleScrollToBottom} onClick={handleScrollToBottom}

View File

@ -9,6 +9,7 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
type ComponentProps,
type HTMLAttributes, type HTMLAttributes,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -32,7 +33,7 @@ import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
@ -473,11 +474,12 @@ export function ArtifactFileDetail({
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
zoom={zoom} zoom={zoom}
threadId={threadId}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<div className="min-h-full mb-[180px] rounded-b-[10px] bg-white p-0 mb-0"> <div className="min-h-full mb-[207px] rounded-b-[10px] bg-white p-0 mb-0">
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none py-[20px]" className="size-full resize-none rounded-none border-none py-[20px]"
value={displayContent ?? ""} value={displayContent ?? ""}
@ -487,14 +489,13 @@ export function ArtifactFileDetail({
</div> </div>
)} )}
{!isCodeFile && ( {!isCodeFile && (
<div className="h-full mb-[180px]"> <PreviewIframe
<iframe className="size-full border-0"
className="size-full border-0" containerClassName="h-full mb-[207px]"
srcDoc={artifactViewerSrcDoc} srcDoc={artifactViewerSrcDoc}
sandbox="allow-same-origin allow-scripts allow-downloads" sandbox="allow-same-origin allow-scripts allow-downloads"
title={`Artifact preview: ${fileName}`} title={`Artifact preview: ${fileName}`}
/> />
</div>
)} )}
</ArtifactContent> </ArtifactContent>
</Artifact> </Artifact>
@ -505,17 +506,22 @@ export function ArtifactFilePreview({
content, content,
language, language,
zoom = 100, zoom = 100,
threadId,
}: { }: {
content: string; content: string;
language: string; language: string;
zoom?: number; zoom?: number;
threadId?: string;
}) { }) {
const zoomScale = zoom / 100; const zoomScale = zoom / 100;
const normalizedContent = useMemo(() => {
return rewriteArtifactImagePaths(content ?? "", threadId);
}, [content, threadId]);
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div <div
className={cn("w-full bg-white mb-[207px] p-[20px]")} className={cn("w-full bg-white mb-[207px] p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties} style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
> >
<Streamdown <Streamdown
@ -523,7 +529,7 @@ export function ArtifactFilePreview({
{...streamdownPlugins} {...streamdownPlugins}
components={{ a: CitationLink }} components={{ a: CitationLink }}
> >
{content ?? ""} {normalizedContent}
</Streamdown> </Streamdown>
</div> </div>
@ -531,21 +537,81 @@ export function ArtifactFilePreview({
} }
if (language === "html") { if (language === "html") {
return ( return (
<div className="h-full mb-[180px]"> <PreviewIframe
<iframe className="size-full"
className="size-full" containerClassName="h-full mb-[207px]"
title="Artifact preview" title="Artifact preview"
srcDoc={content} srcDoc={normalizedContent}
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }} style={{ zoom: zoomScale }}
/> />
</div>
); );
} }
return null; return null;
} }
function PreviewIframe({
className,
containerClassName,
onLoad,
src,
srcDoc,
...props
}: ComponentProps<"iframe"> & {
containerClassName?: string;
}) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
}, [src, srcDoc]);
return (
<div className={cn("relative", containerClassName)}>
<iframe
className={className}
src={src}
srcDoc={srcDoc}
onLoad={(event) => {
setIsLoading(false);
onLoad?.(event);
}}
{...props}
/>
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function rewriteArtifactImagePaths(content: string, threadId?: string) {
if (!threadId || !/\/?mnt\/user-data\//.test(content)) {
return content;
}
const markdownRewritten = content.replace(
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
(_full, alt, rawPath) => {
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
const artifactUrl = resolveArtifactURL(normalizedPath, threadId);
return `![${alt}](${artifactUrl})`;
},
);
return markdownRewritten.replace(
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi,
(_full, prefix, quote, rawPath) => {
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
const artifactUrl = resolveArtifactURL(normalizedPath, threadId);
return `${prefix}${quote}${artifactUrl}${quote}`;
},
);
}
type ArtifactPreviewKind = type ArtifactPreviewKind =
| "html" | "html"
| "image" | "image"

View File

@ -4,6 +4,7 @@ import type { UseStream } from "@langchain/langgraph-sdk/react";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@ -37,6 +38,8 @@ export function MessageList({
messagesOverride, messagesOverride,
suppressThreadLoading = false, suppressThreadLoading = false,
paddingBottom = 160, paddingBottom = 160,
showScrollToBottomButton = false,
scrollButtonClassName,
}: { }: {
className?: string; className?: string;
threadId?: string; threadId?: string;
@ -45,6 +48,8 @@ export function MessageList({
messagesOverride?: Message[]; messagesOverride?: Message[];
suppressThreadLoading?: boolean; suppressThreadLoading?: boolean;
paddingBottom?: number; paddingBottom?: number;
showScrollToBottomButton?: boolean;
scrollButtonClassName?: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
@ -205,9 +210,19 @@ export function MessageList({
/> />
); );
})} })}
{thread.isLoading && <StreamingIndicator className="my-4" />} {thread.isLoading && messages.length > 0 && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} /> <div style={{ height: `${paddingBottom}px` }} />
</ConversationContent> </ConversationContent>
{/* showScrollToBottomButton */}
{ showScrollToBottomButton && (
<ConversationScrollButton
className={cn(
"z-20 rounded-full border bg-white/90 shadow-sm backdrop-blur-sm",
scrollButtonClassName,
)}
title="滚动到底部"
/>
)}
</Conversation> </Conversation>
); );
} }

View File

@ -12,6 +12,16 @@ The user provides frontend requirements: a component, page, application, or inte
**MANDATORY**: The entry HTML file MUST be named `index.html`. This is a strict requirement for all generated frontend projects to ensure compatibility with standard web hosting and deployment workflows. **MANDATORY**: The entry HTML file MUST be named `index.html`. This is a strict requirement for all generated frontend projects to ensure compatibility with standard web hosting and deployment workflows.
### CDN Requirement (China-Friendly)
When generating plain HTML projects that reference third-party JS/CSS from CDN, prefer China-friendly CDN links by default:
1. `https://cdn.bootcdn.net` (preferred)
2. `https://cdn.staticfile.net` (fallback)
3. `https://registry.npmmirror.com` (for npm package file URLs when needed)
Avoid `unpkg.com` and `cdnjs.cloudflare.com` as primary CDN links in generated HTML.
## Design Thinking ## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction: Before coding, understand the context and commit to a BOLD aesthetic direction: