🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
import { useStore } from '@nanostores/react';
import classNames from 'classnames';
import { AnimatePresence, motion } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useEffect, useRef, useState } from 'react';
import { type BundledLanguage, type BundledTheme, createHighlighter, type HighlighterGeneric } from 'shiki';
import type { ActionState } from '~/lib/runtime/action-runner';
import { webBuilderStore } from '~/lib/stores/web-builder';
import { cubicEasingFn } from '~/utils/easings';
const highlighterOptions = {
langs: ['shell'],
themes: ['light-plus', 'dark-plus'],
};
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
if (import.meta.hot) {
import.meta.hot.data.shellHighlighter = shellHighlighter;
}
interface ArtifactProps {
messageId: string;
}
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(webBuilderStore.chatStore.artifacts);
const artifact = artifacts[messageId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
return Object.values(actions);
}),
);
const toggleActions = () => {
userToggledActions.current = true;
setShowActions(!showActions);
};
useEffect(() => {
const actionsMap = artifact.runner.actions.get();
Object.entries(actionsMap).forEach(([actionId, action]) => {
if (action.status === 'running' || action.status === 'pending') {
artifact.runner.actions.setKey(actionId, {
...action,
status: 'aborted',
});
}
});
}, []);
useEffect(() => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
if (actions.length !== 0 && artifact.type === 'bundled') {
const finished = !actions.find((action) => action.status !== 'complete');
if (allActionFinished !== finished) {
setAllActionFinished(finished);
}
}
}, [actions]);
return (
<div className="artifact border border-upage-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-upage-elements-artifacts-background hover:bg-upage-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = webBuilderStore.showWorkbench.get();
webBuilderStore.showWorkbench.set(!showWorkbench);
}}
>
{artifact.type == 'bundled' && (
<>
<div className="p-4">
{allActionFinished ? (
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
) : (
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
)}
</div>
<div className="bg-upage-elements-artifacts-borderColor w-[1px]" />
</>
)}
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-upage-elements-textPrimary font-medium leading-5 text-sm">
{artifact?.title}
</div>
<div className="w-full w-full text-upage-elements-textSecondary text-xs mt-0.5"> WebBuilder</div>
</div>
</button>
<div className="bg-upage-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-upage-elements-artifacts-background hover:bg-upage-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-upage-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-upage-elements-actions-background">
<ActionList actions={actions} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
interface ActionListProps {
actions: ActionState[];
}
const actionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
function openArtifactInWebBuilder(pageName: string, rootDomId: string) {
if (webBuilderStore.currentView.get() !== 'code') {
webBuilderStore.currentView.set('code');
}
webBuilderStore.setSelectedPage(pageName);
webBuilderStore.editorStore.scrollToElement(rootDomId);
}
const ActionList = memo(({ actions }: ActionListProps) => {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<ul className="list-none space-y-2.5">
{actions.map((action, index) => {
const { status } = action;
return (
<motion.li
key={index}
variants={actionVariants}
initial="hidden"
animate="visible"
transition={{
duration: 0.2,
ease: cubicEasingFn,
}}
>
<div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? (
<div className="i-ph:check"></div>
) : status === 'failed' || status === 'aborted' ? (
<div className="i-ph:x"></div>
) : null}
</div>
<div>
{action.action === 'add' ? 'Create' : action.action === 'update' ? 'Update' : 'Delete'}{' '}
<code
className="bg-upage-ele ments-artifacts-inlineCode-background text-upage-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-upage-elements-item-contentAccent hover:underline cursor-pointer"
onClick={() => openArtifactInWebBuilder(action.pageName, action.rootDomId)}
>
{action.id}
</code>
</div>
</div>
</motion.li>
);
})}
</ul>
</motion.div>
);
});
function getIconColor(status: ActionState['status']) {
switch (status) {
case 'pending': {
return 'text-upage-elements-textTertiary';
}
case 'running': {
return 'text-upage-elements-loader-progress';
}
case 'complete': {
return 'text-upage-elements-icon-success';
}
case 'aborted': {
return 'text-upage-elements-textSecondary';
}
case 'failed': {
return 'text-upage-elements-icon-error';
}
default: {
return undefined;
}
}
}

View File

@@ -0,0 +1,54 @@
import { memo } from 'react';
import Popover from '~/components/ui/Popover';
import Tooltip from '~/components/ui/Tooltip';
import type { ParsedUIMessage } from '~/lib/stores/ai-state';
import { Markdown } from './Markdown';
export const AssistantMessage = memo(({ message }: { message: ParsedUIMessage }) => {
return (
<div className="overflow-hidden w-full">
{message.parts.map((part) => {
if (part.type === 'data-summary') {
return (
<div className="flex gap-2 items-center text-sm text-upage-elements-textSecondary mb-1.5">
{part.data.summary && (
<Tooltip tooltip="查看对话上下文" position="top">
<div className="relative group">
<Popover
side="right"
align="start"
trigger={
<button
aria-label="Open context"
className="i-ph:clipboard-text text-lg text-upage-elements-textSecondary cursor-pointer transition-all duration-200 ease-out"
/>
}
>
{part.data.summary && (
<div className="max-w-chat">
<div className="summary flex flex-col">
<div className="p-5 border border-upage-elements-borderColor rounded-md bg-upage-elements-background shadow-sm">
<h2 className="text-lg font-medium text-upage-elements-textPrimary border-b border-upage-elements-borderColor pb-3 mb-4 flex items-center gap-2">
<span className="i-ph:note-pencil"></span>
</h2>
<div className="overflow-y-auto max-h-80 text-sm">
<Markdown>{part.data.summary}</Markdown>
</div>
</div>
</div>
</div>
)}
<div className="context"></div>
</Popover>
</div>
</Tooltip>
)}
</div>
);
}
})}
{message.content && <Markdown html>{message.content}</Markdown>}
</div>
);
});

View File

@@ -0,0 +1,55 @@
.BaseChat {
&[data-chat-visible='false'] {
--workbench-inner-width: 100%;
--workbench-left: 0;
.Chat {
--at-apply: upage-ease-cubic-bezier;
transition-property: transform, opacity;
transition-duration: 0.3s;
will-change: transform, opacity;
transform: translateX(-50%);
opacity: 0;
}
}
}
.Chat {
opacity: 1;
}
.PromptEffectContainer {
--prompt-container-offset: 50px;
--prompt-line-stroke-width: 2px;
position: absolute;
pointer-events: none;
inset: calc(var(--prompt-container-offset) / -2);
width: calc(100% + var(--prompt-container-offset));
height: calc(100% + var(--prompt-container-offset));
overflow: visible;
z-index: 0;
}
.PromptEffectLine {
width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
rx: 10px;
fill: transparent;
stroke-width: var(--prompt-line-stroke-width);
stroke: url(#line-gradient);
stroke-dasharray: 20 30;
stroke-dashoffset: 0;
animation: borderRotate 18s linear infinite;
filter: drop-shadow(0 0 5px rgba(180, 74, 255, 0.7));
}
@keyframes borderRotate {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -500;
}
}

View File

@@ -0,0 +1,158 @@
import { useStore } from '@nanostores/react';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useLoaderData } from '@remix-run/react';
import classNames from 'classnames';
import { useAnimate } from 'framer-motion';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { useShortcuts, useSnapScroll } from '~/lib/hooks';
import { useChatMessage } from '~/lib/hooks/useChatMessage';
import { aiState, setChatId, setChatStarted } from '~/lib/stores/ai-state';
import { webBuilderStore } from '~/lib/stores/web-builder';
import type { ChatMessage, ChatWithMessages } from '~/types/chat';
import { renderLogger } from '~/utils/logger';
import { Menu } from '../sidebar/Menu.client';
import { WebBuilder } from '../webbuilder/WebBuilder.client';
import styles from './BaseChat.module.scss';
import ChatAlert from './ChatAlert';
import { ChatTextarea } from './ChatTextarea';
import { ExamplePrompts } from './ExamplePrompts';
import FilePreview from './FilePreview';
import { Messages } from './Messages.client';
import ProgressCompilation from './ProgressCompilation';
import { ScreenshotStateManager } from './ScreenshotStateManager';
export type ImageData = {
file: File;
base64?: string;
};
export function Chat() {
renderLogger.trace('Chat');
const { id, chat } = useLoaderData<{ id?: string; chat: ChatWithMessages }>();
const { showChat, chatStarted } = useStore(aiState);
const actionAlert = useStore(webBuilderStore.chatStore.alert);
useShortcuts();
const [animationScope] = useAnimate();
const [scrollRef] = useSnapScroll();
const { progressAnnotations, abort, sendChatMessage } = useChatMessage({
initialId: id,
initialMessages: chat?.messages as unknown as ChatMessage[],
});
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
useEffect(() => {
if (id) {
setChatId(id);
}
}, [id]);
useEffect(() => {
if (!chat) {
return;
}
const { messages } = chat;
if (messages.length > 0) {
setChatStarted(true);
}
webBuilderStore.chatStore.setReloadedMessages(messages.map((m) => m.id));
}, [chat]);
const handleSendMessage = (messageInput?: string) => {
if (!messageInput) {
return;
}
sendChatMessage({ messageContent: messageInput, files: uploadFiles });
};
return (
<>
{
<Tooltip.Provider delayDuration={200}>
<div
ref={animationScope}
data-chat-visible={showChat}
className={classNames(styles.BaseChat, 'relative flex size-full overflow-hidden')}
>
<ClientOnly>{() => <Menu />}</ClientOnly>
<div ref={scrollRef} className="flex flex-col lg:flex-row size-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:w-[var(--chat-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[18vh] max-w-chat mx-auto text-center px-4 lg:px-0">
<h1 className="text-3xl lg:text-6xl font-bold text-upage-elements-textPrimary mb-4 animate-fade-in">
使 UPage
</h1>
<p className="text-md lg:text-xl mb-8 text-upage-elements-textSecondary animate-fade-in animation-delay-200">
</p>
</div>
)}
<div
className={classNames('pt-6 px-1 sm:px-2', {
'h-full flex flex-col': chatStarted,
})}
>
<ClientOnly>
{() => {
return chatStarted ? (
<Messages
ref={scrollRef}
className="flex flex-col w-full flex-1 max-w-chat mb-6 mx-auto z-1 overflow-y-auto"
/>
) : null;
}}
</ClientOnly>
<div
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
'sticky bottom-2': chatStarted,
})}
>
<div className="bg-upage-elements-background-depth-2">
{actionAlert && (
<ChatAlert
postMessage={(message) => {
handleSendMessage?.(message);
}}
/>
)}
</div>
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<div
className={classNames(
'bg-upage-elements-background-depth-2 p-1 rounded-lg border border-upage-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
)}
>
<FilePreview
files={uploadFiles}
onRemove={(index: number) => {
setUploadFiles?.(uploadFiles.filter((_, i) => i !== index));
}}
/>
<ClientOnly>
{() => <ScreenshotStateManager uploadFiles={uploadFiles} setUploadFiles={setUploadFiles} />}
</ClientOnly>
<ChatTextarea
onStopMessage={abort}
onSendMessage={handleSendMessage}
uploadFiles={uploadFiles}
setUploadFiles={setUploadFiles}
/>
</div>
</div>
</div>
<div className="flex flex-col justify-center gap-5">
{!chatStarted &&
ExamplePrompts((_event, messageInput) => {
handleSendMessage?.(messageInput);
})}
</div>
</div>
<ClientOnly>{() => <WebBuilder />}</ClientOnly>
</div>
</div>
</Tooltip.Provider>
}
</>
);
}

View File

@@ -0,0 +1,112 @@
import { useStore } from '@nanostores/react';
import classNames from 'classnames';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useMemo } from 'react';
import { webBuilderStore } from '~/lib/stores/web-builder';
interface Props {
postMessage: (message: string) => void;
}
export default function ChatAlert({ postMessage }: Props) {
const actionAlert = useStore(webBuilderStore.chatStore.alert);
const { description, content } = useMemo(() => actionAlert ?? { description: '', content: '' }, [actionAlert]);
const handlePostMessage = useCallback(
(message: string) => {
postMessage(message);
handleClearAlert();
},
[postMessage],
);
const handleClearAlert = useCallback(() => {
webBuilderStore.chatStore.clearAlert();
}, [webBuilderStore]);
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className={`rounded-lg border border-upage-elements-borderColor bg-upage-elements-background-depth-2 p-4 mb-2`}
>
<div className="flex items-start">
{/* Icon */}
<motion.div
className="flex-shrink-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
>
<div className={`i-ph:warning-duotone text-xl text-upage-elements-button-danger-text`}></div>
</motion.div>
{/* Content */}
<div className="ml-3 flex-1">
<motion.h3
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className={`text-sm font-medium text-upage-elements-textPrimary`}
>
</motion.h3>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className={`mt-2 text-sm text-upage-elements-textSecondary`}
>
<p> UPage </p>
{description && (
<div className="text-xs text-upage-elements-textSecondary p-2 bg-upage-elements-background-depth-3 rounded mt-4 mb-4">
Error: {description}
</div>
)}
</motion.div>
{/* Actions */}
<motion.div
className="mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className={classNames(' flex gap-2')}>
<button
onClick={() => handlePostMessage(`*Fix this preview error* \n\`\`\`js\n${content}\n\`\`\`\n`)}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-upage-elements-button-primary-background',
'hover:bg-upage-elements-button-primary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-upage-elements-button-danger-background',
'text-upage-elements-button-primary-text',
'flex items-center gap-1.5',
)}
>
<div className="i-ph:chat-circle-duotone"></div>
UPage
</button>
<button
onClick={handleClearAlert}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-upage-elements-button-secondary-background',
'hover:bg-upage-elements-button-secondary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-upage-elements-button-secondary-background',
'text-upage-elements-button-secondary-text',
)}
>
</button>
</div>
</motion.div>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,260 @@
import { useStore } from '@nanostores/react';
import classNames from 'classnames';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { useAuth, usePromptEnhancer } from '~/lib/hooks';
import { aiState } from '~/lib/stores/ai-state';
import { IconButton } from '../ui/IconButton';
import { SendButton } from './SendButton.client';
interface ChatTextareaProps {
uploadFiles: File[];
setUploadFiles: (files: File[]) => void;
onSendMessage: (message: string) => void;
onStopMessage: () => void;
}
const TEXTAREA_MIN_HEIGHT = 76;
export const ChatTextarea = ({ uploadFiles, setUploadFiles, onSendMessage, onStopMessage }: ChatTextareaProps) => {
const { isAuthenticated, signIn } = useAuth();
const { chatStarted, isStreaming } = useStore(aiState);
const { enhancedInput, isLoading, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 检测当前 URL 是否包含登录回调参数
useEffect(() => {
if (typeof window !== 'undefined') {
const savedMessage = localStorage.getItem('pendingChatMessage');
// 如果是从登录页面回调回来的,检查 localStorage 中是否有待发送的消息
if (savedMessage && isAuthenticated) {
try {
const msgData = JSON.parse(savedMessage);
requestAnimationFrame(() => {
if (msgData.messageInput) {
setInput(msgData.messageInput);
sendMessage();
}
});
} catch (e) {
console.error('Error parsing saved message:', e);
} finally {
localStorage.removeItem('pendingChatMessage');
}
}
}
}, [isAuthenticated]);
useEffect(() => {
setInput(enhancedInput);
scrollTextArea();
}, [enhancedInput]);
const TEXTAREA_MAX_HEIGHT = useMemo(() => {
return chatStarted ? 400 : 200;
}, [chatStarted]);
const scrollTextArea = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
}, [textareaRef]);
const handleEnhancePrompt = useCallback(async () => {
try {
await enhancePrompt(input);
} catch (error) {
console.error('Error enhancing prompt:', error);
}
}, [input]);
const sendMessage = async () => {
if (!input?.trim()) {
return;
}
onSendMessage(input);
setInput('');
setUploadFiles([]);
resetEnhancer();
textareaRef.current?.blur();
};
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}
}, [input, textareaRef]);
const handleSendMessage = () => {
if (!isAuthenticated) {
if (input) {
const savedMsg = {
messageInput: input,
timestamp: new Date().getTime(),
};
localStorage.setItem('pendingChatMessage', JSON.stringify(savedMsg));
signIn();
return;
}
}
if (sendMessage) {
sendMessage();
}
};
const handlePaste = async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) {
return;
}
const files: File[] = [];
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
handleFileReader(files);
};
const handleFileUpload = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const files = (e.target as HTMLInputElement).files;
handleFileReader(files ? Array.from(files) : []);
};
input.click();
}, [uploadFiles]);
const handleFileReader = (files: File[]) => {
files.forEach((file) => {
setUploadFiles?.([...uploadFiles, file]);
});
};
return (
<div className={classNames('relative shadow-xs backdrop-blur rounded-lg')}>
<textarea
ref={textareaRef}
className={classNames(
'w-full pl-3 pt-3 pr-16 outline-none resize-none text-upage-elements-textPrimary placeholder-upage-elements-textTertiary bg-transparent text-sm',
'transition-[opacity,border,width,padding] duration-200',
'hover:border-upage-elements-focus',
)}
onDragEnter={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.style.border = '2px solid #1488fc';
}}
onDragLeave={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--upage-elements-borderColor)';
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.style.border = '1px solid var(--upage-elements-borderColor)';
const files = Array.from(e.dataTransfer.files);
handleFileReader(files);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
// ignore if using input method engine
if (event.nativeEvent.isComposing) {
return;
}
handleSendMessage?.();
}
}}
value={input}
onChange={(e) => {
setInput(e.target.value);
}}
onPaste={handlePaste}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder={isStreaming ? '正在构建中...' : !chatStarted ? '今天我能帮你做什么?' : '需要我优化哪些地方?'}
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={input.trim().length > 0 || isStreaming}
isStreaming={isStreaming}
onClick={() => {
if (isStreaming) {
onStopMessage?.();
return;
}
if (input.trim().length > 0) {
handleSendMessage?.();
}
}}
/>
)}
</ClientOnly>
<div className="flex justify-between items-center text-sm p-3 pt-2">
<div className="flex gap-1 items-center">
<IconButton title="上传文件" className="transition-all" onClick={() => handleFileUpload()}>
<div className="i-mingcute-attachment-2-line text-xl"></div>
</IconButton>
<IconButton
title="优化提示词"
disabled={input.length === 0 || isLoading}
className={classNames('transition-all', isLoading ? 'opacity-100' : '')}
onClick={handleEnhancePrompt}
>
{isLoading ? (
<div className="i-svg-spinners:90-ring-with-bg text-upage-elements-loader-progress text-xl animate-spin"></div>
) : (
<div className="i-mingcute:quill-pen-ai-line text-xl"></div>
)}
</IconButton>
</div>
{input.length > 3 ? (
<div className="text-xs text-upage-elements-textTertiary">
使 <kbd className="kdb px-1.5 py-0.5 rounded bg-upage-elements-background-depth-2">Shift</kbd> +{' '}
<kbd className="kdb px-1.5 py-0.5 rounded bg-upage-elements-background-depth-2">Return</kbd>
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,10 @@
.CopyButtonContainer {
button:before {
content: 'Copied';
font-size: 12px;
position: absolute;
left: -53px;
padding: 2px 6px;
height: 30px;
}
}

View File

@@ -0,0 +1,82 @@
import classNames from 'classnames';
import { memo, useEffect, useState } from 'react';
import { type BundledLanguage, bundledLanguages, codeToHtml, isSpecialLang, type SpecialLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import styles from './CodeBlock.module.scss';
const logger = createScopedLogger('CodeBlock');
interface CodeBlockProps {
className?: string;
code: string;
language?: BundledLanguage | SpecialLanguage;
theme?: 'light-plus' | 'dark-plus';
disableCopy?: boolean;
}
export const CodeBlock = memo(
({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
const [html, setHTML] = useState<string | undefined>(undefined);
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
if (copied) {
return;
}
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
useEffect(() => {
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
logger.warn(`Unsupported language '${language}'`);
}
logger.trace(`Language = ${language}`);
const processCode = async () => {
setHTML(await codeToHtml(code, { lang: language, theme }));
};
processCode();
}, [code]);
return (
<div className={classNames('relative group text-left', className)}>
<div
className={classNames(
styles.CopyButtonContainer,
'bg-transparent absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
{
'rounded-l-0 opacity-100': copied,
},
)}
>
{!disableCopy && (
<button
className={classNames(
'flex items-center bg-accent-500 p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300 rounded-md transition-theme transition-text-color transition-background transition-border',
{
'before:opacity-0': !copied,
'before:opacity-100': copied,
},
)}
title="Copy Code"
onClick={() => copyToClipboard()}
>
<div className="i-ph:clipboard-text-duotone"></div>
</button>
)}
</div>
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
</div>
);
},
);

View File

@@ -0,0 +1,54 @@
import { AnimatePresence, motion } from 'framer-motion';
import React, { useState } from 'react';
import type { ElementInfoMetadata } from '~/types/message';
import { cubicEasingFn } from '~/utils/easings';
import { ElementPreview } from './ElementPreview';
interface ElementEditPreviewProps {
elementEditInfo: ElementInfoMetadata;
className?: string;
}
export const ElementEditPreview: React.FC<ElementEditPreviewProps> = ({ elementEditInfo, className = '' }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
return (
<div
className={`element-edit-preview p-4 border border-upage-elements-borderColor rounded-lg bg-upage-elements-background-depth-1 shadow-sm ${className}`}
>
<div className="flex items-center justify-between cursor-pointer" onClick={toggleExpand}>
<div className="flex items-center gap-2">
<div className="i-ph:code-block text-upage-elements-textSecondary"></div>
<h3 className="text-sm font-medium text-upage-elements-textSecondary">
: {elementEditInfo.tagName.toLowerCase()}
{elementEditInfo.className && `.${elementEditInfo.className.split(' ')[0]}`}
{elementEditInfo.id && `#${elementEditInfo.id}`}
</h3>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.3, ease: cubicEasingFn }}
className="i-ph:caret-down text-upage-elements-textSecondary"
/>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0, marginTop: 0 }}
animate={{ height: 'auto', opacity: 1, marginTop: 12 }}
exit={{ height: 0, opacity: 0, marginTop: 0 }}
transition={{ duration: 0.3, ease: cubicEasingFn }}
style={{ overflow: 'hidden' }}
>
<ElementPreview element={elementEditInfo} />
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,58 @@
import classNames from 'classnames';
import React, { useMemo } from 'react';
interface ElementPreviewProps {
element: {
tagName: string;
className?: string;
id?: string;
innerHTML?: string;
outerHTML?: string;
};
}
export const ElementPreview: React.FC<ElementPreviewProps> = ({ element }) => {
// 提取元素标识符
const elementIdentifier = useMemo(() => {
const parts = [];
parts.push(element.tagName.toLowerCase());
if (element.className) {
const classes = element.className.split(' ').filter(Boolean);
if (classes.length > 0) {
parts.push(`.${classes[0]}`);
}
}
if (element.id) {
parts.push(`#${element.id}`);
}
return parts.join('');
}, [element]);
// 安全地渲染元素预览
// 注意:使用 dangerouslySetInnerHTML 需要确保内容是安全的
return (
<div className="element-preview p-3 border border-upage-elements-borderColor rounded bg-white">
<div className="flex items-center gap-2 mb-2">
<div className="i-ph:code text-upage-elements-textSecondary"></div>
<div className="text-xs font-mono text-upage-elements-textSecondary">{elementIdentifier}</div>
</div>
<div
className={classNames(
'preview-container p-2 border border-dashed border-upage-elements-borderColor rounded',
'max-h-[200px] overflow-auto',
)}
>
{element.outerHTML ? (
<div dangerouslySetInnerHTML={{ __html: element.outerHTML }} />
) : element.innerHTML ? (
<div dangerouslySetInnerHTML={{ __html: element.innerHTML }} />
) : (
<div className="text-xs text-upage-elements-textTertiary italic"></div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
const EXAMPLE_PROMPTS = [
{ text: '帮我生成一个公司的官网,展示公司的产品,突出公司的优势' },
{ text: '创建个人使用的落地页,展示我的作品以及联系方式' },
{ text: '制作一个漂亮的头像卡片' },
{ text: '制作一个登录表单' },
{ text: '使用 Tailwind CSS 制作一个响应式的导航栏' },
];
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
return (
<div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-6">
<div
className="flex flex-wrap justify-center gap-2"
style={{
animation: '.25s ease-out 0s 1 _fade-and-move-in_g2ptj_1 forwards',
}}
>
{EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
return (
<button
key={index}
onClick={(event) => {
sendMessage?.(event, examplePrompt.text);
}}
className="border border-upage-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-upage-elements-textSecondary hover:text-upage-elements-textPrimary px-3 py-1 text-xs transition-theme transition-text-color transition-background transition-border"
>
{examplePrompt.text}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import React, { memo } from 'react';
interface FilePreviewProps {
files: File[];
onRemove: (index: number) => void;
}
const FilePreview: React.FC<FilePreviewProps> = memo(
({ files, onRemove }) => {
if (!files || files.length === 0) {
return null;
}
return (
<div className="flex flex-row overflow-x-auto -mt-2">
{files.map((file, index) => (
<div key={file.name + file.size} className="mr-2 relative">
<div className="relative pt-4 pr-4">
<img src={URL.createObjectURL(file)} alt={file.name} className="max-h-20" />
<button
onClick={() => onRemove(index)}
className="absolute top-1 right-1 z-10 bg-black rounded-full size-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
>
<div className="i-ph:x size-3 text-gray-200" />
</button>
</div>
</div>
))}
</div>
);
},
(prevProps, nextProps) => {
return prevProps.files === nextProps.files;
},
);
export default FilePreview;

View File

@@ -0,0 +1,171 @@
$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
$code-font-size: 13px;
@mixin not-inside-actions {
&:not(:has(:global(.actions)), :global(.actions *)) {
@content;
}
}
.MarkdownContent {
line-height: 1.6;
color: var(--upage-elements-textPrimary);
> *:not(:last-child) {
margin-block-end: 16px;
}
:global(.artifact) {
margin: 1.5em 0;
}
:is(h1, h2, h3, h4, h5, h6) {
@include not-inside-actions {
margin-block-start: 24px;
margin-block-end: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--upage-elements-textPrimary);
}
}
h1 {
font-size: 2em;
border-bottom: 1px solid var(--upage-elements-borderColor);
padding-bottom: 0.3em;
}
h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--upage-elements-borderColor);
padding-bottom: 0.3em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: #6a737d;
}
p {
white-space: pre-wrap;
&:not(:last-of-type) {
margin-block-start: 0;
margin-block-end: 16px;
}
}
a {
color: var(--upage-elements-messages-linkColor);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
:not(pre) > code {
font-family: $font-mono;
font-size: $code-font-size;
@include not-inside-actions {
border-radius: 6px;
padding: 0.2em 0.4em;
background-color: var(--upage-elements-messages-inlineCode-background);
color: var(--upage-elements-messages-inlineCode-text);
}
}
pre {
padding: 20px 16px;
border-radius: 6px;
}
pre:has(> code) {
font-family: $font-mono;
font-size: $code-font-size;
background: transparent;
overflow-x: auto;
min-width: 0;
}
blockquote {
margin: 0;
padding: 0 1em;
color: var(--upage-elements-textTertiary);
border-left: 0.25em solid var(--upage-elements-borderColor);
}
:is(ul, ol) {
@include not-inside-actions {
padding-left: 2em;
margin-block-start: 0;
margin-block-end: 16px;
}
}
ul {
@include not-inside-actions {
list-style-type: disc;
}
}
ol {
@include not-inside-actions {
list-style-type: decimal;
}
}
li {
@include not-inside-actions {
& + li {
margin-block-start: 8px;
}
> *:not(:last-child) {
margin-block-end: 16px;
}
}
}
img {
max-width: 100%;
box-sizing: border-box;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--upage-elements-borderColor);
border: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin-block-end: 16px;
:is(th, td) {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
tr:nth-child(2n) {
background-color: #f6f8fa;
}
}
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { stripCodeFenceFromArtifact } from './Markdown';
describe('stripCodeFenceFromArtifact', () => {
it('should remove code fences around artifact element', () => {
const input = "```xml\n<div class='__uPageArtifact__'></div>\n```";
const expected = "\n<div class='__uPageArtifact__'></div>\n";
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
});
it('should handle code fence with language specification', () => {
const input = "```typescript\n<div class='__uPageArtifact__'></div>\n```";
const expected = "\n<div class='__uPageArtifact__'></div>\n";
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
});
it('should not modify content without artifacts', () => {
const input = '```\nregular code block\n```';
expect(stripCodeFenceFromArtifact(input)).toBe(input);
});
it('should handle empty input', () => {
expect(stripCodeFenceFromArtifact('')).toBe('');
});
it('should handle artifact without code fences', () => {
const input = "<div class='__uPageArtifact__'></div>";
expect(stripCodeFenceFromArtifact(input)).toBe(input);
});
it('should handle multiple artifacts but only remove fences around them', () => {
const input = [
'Some text',
'```typescript',
"<div class='__uPageArtifact__'></div>",
'```',
'```',
'regular code',
'```',
].join('\n');
const expected = ['Some text', '', "<div class='__uPageArtifact__'></div>", '', '```', 'regular code', '```'].join(
'\n',
);
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,124 @@
import { memo, useMemo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import { allowedHTMLElements, rehypePlugins, remarkPlugins } from '~/utils/markdown';
import { Artifact } from './Artifact';
import { CodeBlock } from './CodeBlock';
import styles from './Markdown.module.scss';
import ThoughtBox from './ThoughtBox';
const logger = createScopedLogger('MarkdownComponent');
interface MarkdownProps {
children: string;
html?: boolean;
limitedMarkdown?: boolean;
}
export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
logger.trace('Render');
const components = useMemo(() => {
return {
div: ({ className, children, node, ...props }) => {
if (className?.includes('__uPageArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
return <Artifact messageId={messageId} />;
}
if (className?.includes('__uPageThought__')) {
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
}
return (
<div className={className} {...props}>
{children}
</div>
);
},
pre: (props) => {
const { children, node, ...rest } = props;
const [firstChild] = node?.children ?? [];
if (
firstChild &&
firstChild.type === 'element' &&
firstChild.tagName === 'code' &&
firstChild.children[0].type === 'text'
) {
const { className, ...rest } = firstChild.properties;
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
}
return <pre {...rest}>{children}</pre>;
},
} satisfies Components;
}, []);
return (
<div className={styles.MarkdownContent}>
<ReactMarkdown
allowedElements={allowedHTMLElements}
components={components}
remarkPlugins={remarkPlugins(limitedMarkdown)}
rehypePlugins={rehypePlugins(html)}
>
{stripCodeFenceFromArtifact(children)}
</ReactMarkdown>
</div>
);
});
/**
* Removes code fence markers (```) surrounding an artifact element while preserving the artifact content.
* This is necessary because artifacts should not be wrapped in code blocks when rendered for rendering action list.
*
* @param content - The markdown content to process
* @returns The processed content with code fence markers removed around artifacts
*
* @example
* // Removes code fences around artifact
* const input = "```xml\n<div class='__uPageArtifact__'></div>\n```";
* stripCodeFenceFromArtifact(input);
* // Returns: "\n<div class='__uPageArtifact__'></div>\n"
*
* @remarks
* - Only removes code fences that directly wrap an artifact (marked with __uPageArtifact__ class)
* - Handles code fences with optional language specifications (e.g. ```xml, ```typescript)
* - Preserves original content if no artifact is found
* - Safely handles edge cases like empty input or artifacts at start/end of content
*/
export const stripCodeFenceFromArtifact = (content: string) => {
if (!content || !content.includes('__uPageArtifact__')) {
return content;
}
const lines = content.split('\n');
const artifactLineIndex = lines.findIndex((line) => line.includes('__uPageArtifact__'));
// Return original content if artifact line not found
if (artifactLineIndex === -1) {
return content;
}
// Check previous line for code fence
if (artifactLineIndex > 0 && lines[artifactLineIndex - 1]?.trim().match(/^```\w*$/)) {
lines[artifactLineIndex - 1] = '';
}
if (artifactLineIndex < lines.length - 1 && lines[artifactLineIndex + 1]?.trim().match(/^```$/)) {
lines[artifactLineIndex + 1] = '';
}
return lines.join('\n');
};

View File

@@ -0,0 +1,192 @@
import { useStore } from '@nanostores/react';
import { useLocation } from '@remix-run/react';
import classNames from 'classnames';
import type { ForwardedRef } from 'react';
import { Fragment, forwardRef, memo, useEffect, useMemo, useRef } from 'react';
import { toast } from 'sonner';
import WithTooltip from '~/components/ui/Tooltip';
import { useAuth } from '~/lib/hooks/useAuth';
import { useChatOperate } from '~/lib/hooks/useChatOperate';
import { useSnapScroll } from '~/lib/hooks/useSnapScroll';
import { aiState, type ParsedUIMessage } from '~/lib/stores/ai-state';
import { AssistantMessage } from './AssistantMessage';
import styles from './Messages.module.scss';
import { UserMessage } from './UserMessage';
interface MessagesProps {
id?: string;
className?: string;
isStreaming?: boolean;
}
const MessageItem = memo(
forwardRef<
HTMLDivElement,
{
message: ParsedUIMessage;
index: number;
isFirst: boolean;
isLast: boolean;
isStreaming: boolean;
userInfo: any;
onRewind: (messageId: string) => void;
onFork: (messageId: string) => void;
}
>(({ message, index, isFirst, isLast, isStreaming, userInfo, onRewind, onFork }, ref) => {
const { role, id: messageId } = message;
const isUserMessage = role === 'user';
const isHidden = message.metadata?.isHidden;
if (isHidden) {
return <Fragment key={index} />;
}
return (
<div
ref={ref}
className={classNames(styles.messageItem, 'flex gap-4 p-5 w-full', {
[styles.userMessage]: isUserMessage,
[styles.assistantMessage]: !isUserMessage && (!isStreaming || !isLast),
[styles.streamingLastMessage]: !isUserMessage && isStreaming && isLast,
'mt-6': !isFirst,
})}
>
{isUserMessage && (
<div
className={classNames(
'flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start',
)}
>
{userInfo?.picture ? (
<img
src={userInfo.picture}
alt={userInfo?.user || userInfo.username || 'User'}
className="size-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="i-ph:user-fill text-2xl" />
)}
</div>
)}
<div className={classNames(styles.messageContent, 'grid grid-col-1 w-full')}>
{isUserMessage ? <UserMessage message={message} /> : <AssistantMessage message={message} />}
</div>
{!isUserMessage && (
<div className="flex gap-2 flex-col lg:flex-row">
{messageId && (
<WithTooltip tooltip="恢复到此消息">
<button
onClick={() => onRewind(messageId)}
key="i-ph:arrow-u-up-left"
className={classNames(
styles.actionButton,
'i-ph:arrow-u-up-left',
'text-xl text-upage-elements-textSecondary hover:text-upage-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
)}
<WithTooltip tooltip="从此消息分叉聊天">
<button
onClick={() => onFork(messageId)}
key="i-ph:git-fork"
className={classNames(
styles.actionButton,
'i-ph:git-fork',
'text-xl text-upage-elements-textSecondary hover:text-upage-elements-textPrimary transition-colors',
)}
/>
</WithTooltip>
</div>
)}
</div>
);
}),
);
export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
const { id } = props;
const location = useLocation();
const { userInfo } = useAuth();
const { forkMessage } = useChatOperate();
const { chatId, parseMessages, isStreaming } = useStore(aiState);
const containerRef = useRef<HTMLDivElement | null>(null);
// 使用useSnapScroll钩子获取自动滚动功能
const [messageRef, scrollRef] = useSnapScroll();
// 组合refs: 外部传入的ref、内部的containerRef和scrollRef
useEffect(() => {
if (containerRef.current) {
scrollRef(containerRef.current);
}
// 连接外部ref和内部ref
if (typeof ref === 'function') {
ref(containerRef.current);
} else if (ref) {
ref.current = containerRef.current;
}
}, [ref, scrollRef]);
const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
searchParams.set('rewindTo', messageId);
window.location.search = searchParams.toString();
};
const handleFork = async (messageId: string) => {
try {
if (!chatId) {
return;
}
const id = await forkMessage(chatId, messageId);
window.location.href = `/chat/${id}`;
} catch (error) {
toast.error('分叉聊天失败: ' + (error as Error).message);
}
};
const messageItems = useMemo(() => {
return parseMessages.map((message, index) => {
const isFirst = index === 0;
const isLast = index === parseMessages.length - 1;
const refToApply = isLast ? messageRef : undefined;
return (
<MessageItem
ref={refToApply}
key={`${message.id || index}`}
message={message}
index={index}
isFirst={isFirst}
isLast={isLast}
isStreaming={isStreaming}
userInfo={userInfo}
onRewind={handleRewind}
onFork={handleFork}
/>
);
});
}, [isStreaming, parseMessages, userInfo, messageRef]);
return (
<div id={id} className={classNames(props.className, 'px-2')} ref={containerRef}>
{parseMessages.length > 0 ? messageItems : null}
{isStreaming && (
<div
className="text-center w-full text-upage-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"
ref={messageRef}
></div>
)}
</div>
);
},
);

View File

@@ -0,0 +1,57 @@
.messageItem {
margin-bottom: 1rem;
transition: transform 0.2s ease, opacity 0.2s ease;
&:hover {
transform: translateY(-1px);
}
}
.userMessage {
background-color: var(--upage-elements-background-depth-1);
border-radius: 1.25rem 1.25rem 0.25rem 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.assistantMessage {
background-color: var(--upage-elements-background-depth-2);
border-radius: 1.25rem 1.25rem 1.25rem 0.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.avatar {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--upage-elements-icon-success);
border: 2px solid var(--upage-elements-background);
}
}
.actionButton {
opacity: 0.7;
transition: opacity 0.2s ease, transform 0.2s ease;
&:hover {
opacity: 1;
transform: scale(1.1);
}
}
.messageContent {
overflow: hidden;
transition: all 0.3s ease;
}
.streamingLastMessage {
background-image: linear-gradient(to bottom, var(--upage-elements-messages-background) 30%, transparent 100%);
border-radius: 1.25rem 1.25rem 1.25rem 0.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

View File

@@ -0,0 +1,315 @@
import classNames from 'classnames';
import type { KeyboardEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types';
import type { ProviderInfo } from '~/types/model';
interface ModelSelectorProps {
model?: string;
setModel?: (model: string) => void;
provider?: ProviderInfo;
setProvider?: (provider: ProviderInfo) => void;
modelList: ModelInfo[];
providerList: ProviderInfo[];
apiKeys: Record<string, string>;
modelLoading?: string;
}
export const ModelSelector = ({
model,
setModel,
provider,
setProvider,
modelList,
providerList,
modelLoading,
}: ModelSelectorProps) => {
const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLDivElement[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter models based on search query
const filteredModels = [...modelList]
.filter((e) => e.provider === provider?.name && e.name)
.filter(
(model) =>
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
);
// Reset focused index when search query changes or dropdown opens/closes
useEffect(() => {
setFocusedIndex(-1);
}, [modelSearchQuery, isModelDropdownOpen]);
// Focus search input when dropdown opens
useEffect(() => {
if (isModelDropdownOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isModelDropdownOpen]);
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isModelDropdownOpen) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) => {
const next = prev + 1;
if (next >= filteredModels.length) {
return 0;
}
return next;
});
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) => {
const next = prev - 1;
if (next < 0) {
return filteredModels.length - 1;
}
return next;
});
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
const selectedModel = filteredModels[focusedIndex];
setModel?.(selectedModel.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}
break;
case 'Escape':
e.preventDefault();
setIsModelDropdownOpen(false);
setModelSearchQuery('');
break;
case 'Tab':
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
setIsModelDropdownOpen(false);
}
break;
}
};
// Focus the selected option
useEffect(() => {
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedIndex]);
// Update enabled providers when cookies change
useEffect(() => {
// If current provider is disabled, switch to first enabled provider
if (providerList.length === 0) {
return;
}
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
const firstEnabledProvider = providerList[0];
setProvider?.(firstEnabledProvider);
// Also update the model to the first available one for the new provider
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
if (firstModel) {
setModel?.(firstModel.name);
}
}
}, [providerList, provider, setProvider, modelList, setModel]);
if (providerList.length === 0) {
return (
<div className="mb-2 p-4 rounded-lg border border-upage-elements-borderColor bg-upage-elements-prompt-background text-upage-elements-textPrimary">
<p className="text-center">
No providers are currently enabled. Please enable at least one provider in the settings to start using the
chat.
</p>
</div>
);
}
return (
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
<select
value={provider?.name ?? ''}
onChange={(e) => {
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
if (newProvider && setProvider) {
setProvider(newProvider);
}
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
if (firstModel && setModel) {
setModel(firstModel.name);
}
}}
className="flex-1 p-2 rounded-lg border border-upage-elements-borderColor bg-upage-elements-prompt-background text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-focus transition-all"
>
{providerList.map((provider: ProviderInfo) => (
<option key={provider.name} value={provider.name}>
{provider.name}
</option>
))}
</select>
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
<div
className={classNames(
'w-full p-2 rounded-lg border border-upage-elements-borderColor',
'bg-upage-elements-prompt-background text-upage-elements-textPrimary',
'focus-within:outline-none focus-within:ring-2 focus-within:ring-upage-elements-focus',
'transition-all cursor-pointer',
isModelDropdownOpen ? 'ring-2 ring-upage-elements-focus' : undefined,
)}
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsModelDropdownOpen(!isModelDropdownOpen);
}
}}
role="combobox"
aria-expanded={isModelDropdownOpen}
aria-controls="model-listbox"
aria-haspopup="listbox"
tabIndex={0}
>
<div className="flex items-center justify-between">
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
<div
className={classNames(
'i-ph:caret-down size-4 text-upage-elements-textSecondary opacity-75',
isModelDropdownOpen ? 'rotate-180' : undefined,
)}
/>
</div>
</div>
{isModelDropdownOpen && (
<div
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-upage-elements-borderColor bg-upage-elements-background-depth-2 shadow-lg"
role="listbox"
id="model-listbox"
>
<div className="px-2 pb-2">
<div className="relative">
<input
ref={searchInputRef}
type="text"
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder="Search models..."
className={classNames(
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
'bg-upage-elements-background-depth-2 border border-upage-elements-borderColor',
'text-upage-elements-textPrimary placeholder:text-upage-elements-textTertiary',
'focus:outline-none focus:ring-2 focus:ring-upage-elements-focus',
'transition-all',
)}
onClick={(e) => e.stopPropagation()}
role="searchbox"
aria-label="Search models"
/>
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-upage-elements-textTertiary" />
</div>
</div>
</div>
<div
className={classNames(
'max-h-60 overflow-y-auto',
'sm:scrollbar-none',
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-upage-elements-borderColor',
'[&::-webkit-scrollbar-thumb]:hover:bg-upage-elements-borderColorHover',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-track]:bg-upage-elements-background-depth-2',
'[&::-webkit-scrollbar-track]:rounded-full',
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-upage-elements-borderColor/50',
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-upage-elements-borderColor',
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
)}
>
{modelLoading === 'all' || modelLoading === provider?.name ? (
<div className="px-3 py-2 text-sm text-upage-elements-textTertiary">Loading...</div>
) : filteredModels.length === 0 ? (
<div className="px-3 py-2 text-sm text-upage-elements-textTertiary">No models found</div>
) : (
filteredModels.map((modelOption, index) => (
<div
ref={(el) => {
if (el) {
optionsRef.current[index] = el;
}
}}
key={index}
role="option"
aria-selected={model === modelOption.name}
className={classNames(
'px-3 py-2 text-sm cursor-pointer',
'hover:bg-upage-elements-background-depth-3',
'text-upage-elements-textPrimary',
'outline-none',
model === modelOption.name || focusedIndex === index
? 'bg-upage-elements-background-depth-2'
: undefined,
focusedIndex === index ? 'ring-1 ring-inset ring-upage-elements-focus' : undefined,
)}
onClick={(e) => {
e.stopPropagation();
setModel?.(modelOption.name);
setIsModelDropdownOpen(false);
setModelSearchQuery('');
}}
tabIndex={focusedIndex === index ? 0 : -1}
>
{modelOption.label}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { useEffect, useState } from 'react';
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
import { DeploymentPlatformEnum } from '~/types/deployment';
export function NetlifyDeploymentLink() {
const { getDeploymentByPlatform } = useChatDeployment();
const [deploymentUrl, setDeploymentUrl] = useState<string | undefined>(undefined);
useEffect(() => {
setDeploymentUrl(getDeploymentByPlatform(DeploymentPlatformEnum.NETLIFY)?.url || '');
}, [getDeploymentByPlatform]);
return (
deploymentUrl && (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center size-8 rounded hover:bg-upage-elements-item-backgroundActive text-upage-elements-textSecondary hover:text-[#00AD9F] z-50"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className="i-ph:link size-4 hover:text-blue-400" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-3 py-2 rounded bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-xs z-50"
sideOffset={5}
>
{deploymentUrl}
<Tooltip.Arrow className="fill-upage-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
);
}

View File

@@ -0,0 +1,118 @@
import classNames from 'classnames';
import { AnimatePresence, motion } from 'framer-motion';
import { useMemo, useState } from 'react';
import type { ProgressAnnotation } from '~/types/message';
import { cubicEasingFn } from '~/utils/easings';
export default function ProgressCompilation({ data }: { data?: ProgressAnnotation[] }) {
const [expanded, setExpanded] = useState(false);
const progressList = useMemo(() => {
if (!data || data.length == 0) {
return [];
}
const progressMap = new Map<string, ProgressAnnotation>();
data.forEach((x) => {
const existingProgress = progressMap.get(x.label);
if (existingProgress && existingProgress.status === 'complete') {
return;
}
progressMap.set(x.label, x);
});
return Array.from(progressMap.values()).sort((a, b) => a.order - b.order);
}, [data]);
if (progressList.length === 0) {
return <></>;
}
return (
<AnimatePresence>
<div
className={classNames(
'bg-upage-elements-background-depth-2',
'border border-upage-elements-borderColor',
'shadow-lg rounded-lg relative w-full max-w-chat mx-auto z-prompt',
'p-1',
)}
>
<div
className={classNames(
'bg-upage-elements-item-backgroundAccent',
'py-1 px-1.5 rounded-md text-upage-elements-item-contentAccent',
'flex items-center',
)}
>
<div className="flex-1">
<AnimatePresence>
{expanded ? (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
{progressList.map((x, i) => {
return <ProgressItem key={i} progress={x} />;
})}
</motion.div>
) : (
<ProgressItem progress={progressList.slice(-1)[0]} />
)}
</AnimatePresence>
</div>
{progressList.length > 1 && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="p-1 rounded-lg bg-upage-elements-item-backgroundAccent hover:bg-upage-elements-artifacts-backgroundHover"
onClick={() => setExpanded((v) => !v)}
>
<div className={expanded ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</motion.button>
)}
</div>
</div>
</AnimatePresence>
);
}
interface ProgressItemProps {
progress: ProgressAnnotation;
}
const ProgressItem = ({ progress }: ProgressItemProps) => {
return (
<motion.div
className={classNames('flex text-sm gap-3 items-center justify-between', {
'text-upage-elements-textSuccess': progress.status === 'complete',
'text-upage-elements-textError': progress.status === 'stopped',
})}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="flex items-center gap-1.5">
<div>
{progress.status === 'in-progress' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : progress.status === 'complete' ? (
<div className="i-ph:check"></div>
) : progress.status === 'stopped' ? (
<div className="i-ph:x"></div>
) : progress.status === 'warning' ? (
<div className="i-ph:warning"></div>
) : null}
</div>
{progress.message}
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
interface ScreenshotStateManagerProps {
setUploadFiles?: (files: File[]) => void;
uploadFiles: File[];
}
export const ScreenshotStateManager = ({ setUploadFiles, uploadFiles }: ScreenshotStateManagerProps) => {
useEffect(() => {
if (setUploadFiles) {
(window as any).__UPAGE_SET_UPLOADED_FILES__ = setUploadFiles;
(window as any).__UPAGE_UPLOADED_FILES__ = uploadFiles;
}
return () => {
delete (window as any).__UPAGE_SET_UPLOADED_FILES__;
delete (window as any).__UPAGE_UPLOADED_FILES__;
};
}, [setUploadFiles, uploadFiles]);
return null;
};

View File

@@ -0,0 +1,43 @@
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps {
show: boolean;
isStreaming?: boolean;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onImagesSelected?: (images: File[]) => void;
}
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonProps) => {
return (
<AnimatePresence>
{show ? (
<motion.button
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme transition-text-color transition-background transition-border disabled:opacity-50 disabled:cursor-not-allowed"
transition={{ ease: customEasingFn, duration: 0.17 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
disabled={disabled}
onClick={(event) => {
event.preventDefault();
if (!disabled) {
onClick?.(event);
}
}}
>
<div className="text-lg">
{!isStreaming ? (
<div className="i-mingcute:arrow-right-line"></div>
) : (
<div className="i-mingcute:stop-circle-line"></div>
)}
</div>
</motion.button>
) : null}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,27 @@
import classNames from 'classnames';
import { IconButton } from '~/components/ui/IconButton';
export const SpeechRecognitionButton = ({
isListening,
onStart,
onStop,
disabled,
}: {
isListening: boolean;
onStart: () => void;
onStop: () => void;
disabled: boolean;
}) => {
return (
<IconButton
title={isListening ? 'Stop listening' : 'Start speech recognition'}
disabled={disabled}
className={classNames('transition-all', {
'text-upage-elements-item-contentAccent': isListening,
})}
onClick={isListening ? onStop : onStart}
>
{isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
</IconButton>
);
};

View File

@@ -0,0 +1,44 @@
import { type PropsWithChildren, useState } from 'react';
const ThoughtBox = ({ title, children }: PropsWithChildren<{ title: string }>) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
onClick={() => setIsExpanded(!isExpanded)}
className={`
bg-upage-elements-background-depth-2
shadow-md
rounded-lg
cursor-pointer
transition-all
duration-300
${isExpanded ? 'max-h-96' : 'max-h-13'}
overflow-hidden
border border-upage-elements-borderColor
`}
>
<div className="p-4 flex items-center gap-4 rounded-lg text-upage-elements-textSecondary font-medium leading-5 text-sm border border-upage-elements-borderColor">
<div className="i-ph:brain-thin text-2xl" />
<div className="div">
<span> {title}</span>{' '}
{!isExpanded && <span className="text-upage-elements-textTertiary"> - Click to expand</span>}
</div>
</div>
<div
className={`
transition-opacity
duration-300
p-4
rounded-lg
overflow-auto
${isExpanded ? 'opacity-100' : 'opacity-0'}
`}
>
{children}
</div>
</div>
);
};
export default ThoughtBox;

View File

@@ -0,0 +1,36 @@
import type { FileUIPart } from 'ai';
import type { UPageUIMessage } from '~/types/message';
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import { ElementEditPreview } from './ElementEditPreview';
import { Markdown } from './Markdown';
export function UserMessage({ message }: { message: UPageUIMessage }) {
const parts = message.parts;
const textContent = stripMetadata(parts.find((part) => part.type === 'text')?.text || '');
const images = parts.filter((part) => part.type === 'file' && part.mediaType.startsWith('image')) as FileUIPart[];
const elementInfo = message.metadata?.elementInfo;
return (
<div className="overflow-hidden pt-2">
<div className="flex flex-col gap-3">
{textContent && <Markdown html>{textContent}</Markdown>}
{images.map((item, index) => (
<img
key={index}
src={item.url}
alt={item.filename || `Image ${index + 1}`}
className="max-w-full h-auto rounded-lg"
style={{ maxHeight: '512px', objectFit: 'contain' }}
/>
))}
{elementInfo && <ElementEditPreview elementEditInfo={elementInfo} className="mt-3" />}
</div>
</div>
);
}
function stripMetadata(content: string) {
const artifactRegex = /<uPageArtifact\s+[^>]*>[\s\S]*?<\/uPageArtifact>/gm;
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').replace(artifactRegex, '');
}

View File

@@ -0,0 +1,44 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { useEffect, useState } from 'react';
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
import { DeploymentPlatformEnum } from '~/types/deployment';
export function VercelDeploymentLink() {
const [deploymentUrl, setDeploymentUrl] = useState<string | undefined>(undefined);
const { getDeploymentByPlatform } = useChatDeployment();
useEffect(() => {
setDeploymentUrl(getDeploymentByPlatform(DeploymentPlatformEnum.VERCEL)?.url || '');
}, [getDeploymentByPlatform]);
return (
deploymentUrl && (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex justify-end items-center justify-center size-8 rounded hover:bg-upage-elements-item-backgroundActive text-upage-elements-textSecondary hover:text-[#000000] z-50"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className={`i-ph:link size-4 hover:text-blue-400`} />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-3 py-2 rounded bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-xs z-50"
sideOffset={5}
>
{deploymentUrl}
<Tooltip.Arrow className="fill-upage-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
);
}

View File

@@ -0,0 +1,44 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { useEffect, useState } from 'react';
import { useChatDeployment } from '~/lib/hooks/useChatDeployment';
import { DeploymentPlatformEnum } from '~/types/deployment';
export function _1PanelDeploymentLink() {
const { getDeploymentByPlatform } = useChatDeployment();
const [deploymentUrl, setDeploymentUrl] = useState<string | undefined>(undefined);
useEffect(() => {
setDeploymentUrl(getDeploymentByPlatform(DeploymentPlatformEnum._1PANEL)?.url || '');
}, [getDeploymentByPlatform]);
return (
deploymentUrl && (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center size-8 rounded hover:bg-upage-elements-item-backgroundActive text-upage-elements-textSecondary hover:text-[#00AD9F] z-50"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className="i-ph:link size-4 hover:text-blue-400" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-3 py-2 rounded bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-xs z-50"
sideOffset={5}
>
{deploymentUrl}
<Tooltip.Arrow className="fill-upage-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
);
}

View File

@@ -0,0 +1,12 @@
import { IconButton } from '~/components/ui/IconButton';
import WithTooltip from '~/components/ui/Tooltip';
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
return (
<WithTooltip tooltip="导出聊天">
<IconButton title="导出聊天" onClick={() => exportChat?.()}>
<div className="i-ph:download-simple text-xl"></div>
</IconButton>
</WithTooltip>
);
};

View File

@@ -0,0 +1,424 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import classNames from 'classnames';
import { motion, type Transition, type Variants } from 'framer-motion';
import { memo } from 'react';
import { useChatUsage } from '~/lib/hooks/useChatUsage';
import { DialogDescription, DialogTitle } from '../../ui/Dialog';
import { IconButton } from '../../ui/IconButton';
import { ChatUsageVisualization } from './ChatUsageVisualization';
const transition: Transition = {
duration: 0.15,
ease: [0.16, 1, 0.3, 1], // cubicBezier(.16,1,.3,1)
};
const backdropVariants: Variants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
};
const dialogVariants: Variants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
};
interface ChatUsageDialogProps {
isOpen: boolean;
onClose: () => void;
}
export const ChatUsageDialog = memo(({ isOpen, onClose }: ChatUsageDialogProps) => {
const { usageStats, isLoading, refreshUsageStats } = useChatUsage();
const formatNumber = (num: number | null) => {
if (num === null) {
return '0';
}
return num.toLocaleString();
};
const formatLargeNumber = (num: number | null) => {
if (num === null) {
return '0';
}
if (num < 1000) {
return num.toString();
}
if (num < 1000000) {
return `${(num / 1000).toFixed(1)}K`;
}
return `${(num / 1000000).toFixed(1)}M`;
};
// 计算成功率
const successRate = () => {
if (!usageStats) {
return 0;
}
const successCount = usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0;
const totalCount = usageStats.total._count;
return totalCount > 0 ? (successCount / totalCount) * 100 : 0;
};
// 计算平均 token 消耗
const avgTokenPerRequest = () => {
if (!usageStats || usageStats.total._count === 0) {
return 0;
}
return (usageStats.total._sum.totalTokens || 0) / usageStats.total._count;
};
const cardClasses = classNames(
'p-4 rounded-lg shadow-sm',
'bg-upage-elements-bg-depth-1',
'border border-upage-elements-borderColor',
);
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
initial="closed"
animate="open"
exit="closed"
variants={backdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[95vw] max-w-[1000px] max-h-[85vh] flex flex-col',
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<DialogDescription className="sr-only">
API 使Token
</DialogDescription>
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
<DialogTitle>API 使</DialogTitle>
<div className="flex items-center gap-2">
<IconButton
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
onClick={refreshUsageStats}
disabled={isLoading}
className={classNames('text-upage-elements-textTertiary hover:text-upage-elements-textSecondary', {
'opacity-50 cursor-not-allowed': isLoading,
})}
aria-label="刷新统计数据"
title="刷新统计数据"
/>
<RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
/>
</RadixDialog.Close>
</div>
</div>
<div className="flex-1 overflow-auto relative">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 dark:bg-gray-950/70 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm">
<div className="i-ph:spinner-gap-bold animate-spin size-5 text-upage-elements-textTertiary" />
<span className="text-upage-elements-textSecondary font-medium">...</span>
</div>
</div>
)}
{!usageStats ? (
<div className="flex-1 overflow-auto text-center py-12">
<div className="i-ph:chart-line-duotone size-12 mx-auto mb-4 text-upage-elements-textTertiary opacity-80" />
<h3 className="text-lg font-medium text-upage-elements-textPrimary mb-2"></h3>
<p className="text-upage-elements-textSecondary">使使 AI </p>
</div>
) : (
<div className="flex-1 overflow-auto p-6">
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:chat-dots-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
{formatNumber(usageStats.total._count)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token </div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:hash-duotone size-6 text-green-500 dark:text-green-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.totalTokens)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:export-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.inputTokens)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:import-duotone size-6 text-amber-500 dark:text-amber-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.outputTokens)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:chart-pie-slice-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
</h3>
<div className="mt-4">
<div className="flex justify-between items-center mb-2">
<div className="text-3xl font-bold text-upage-elements-textPrimary">
{successRate().toFixed(1)}%
</div>
<div className="text-sm text-upage-elements-textSecondary">
{usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0} /{' '}
{usageStats.total._count}
</div>
</div>
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 dark:bg-green-400 rounded-full"
style={{ width: `${Math.min(100, successRate())}%` }}
/>
</div>
</div>
<div className="mt-4 space-y-2">
{usageStats.byStatus.map((status) => (
<div key={status.status} className="flex justify-between items-center">
<div className="flex items-center">
<div
className={classNames('size-2 rounded-full mr-2', {
'bg-green-500 dark:bg-green-400': status.status === 'SUCCESS',
'bg-red-500 dark:bg-red-400': status.status === 'FAILED',
'bg-yellow-500 dark:bg-yellow-400': status.status === 'PENDING',
'bg-gray-500 dark:bg-gray-400': !['SUCCESS', 'FAILED', 'PENDING'].includes(
status.status,
),
})}
/>
<div className="text-sm text-upage-elements-textSecondary capitalize">
{status.status === 'SUCCESS'
? '成功'
: status.status === 'FAILED'
? '失败'
: status.status === 'PENDING'
? '处理中'
: status.status === 'ABORTED'
? '中止'
: status.status}
</div>
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatNumber(status._count)}
</div>
</div>
))}
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:check-circle-duotone size-5 text-green-500 dark:text-green-400 mr-2" />
Token
</h3>
<div className="space-y-4">
<div>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary">
{formatLargeNumber(avgTokenPerRequest())} Tokens
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<div className="text-sm text-upage-elements-textSecondary mb-2">Token </div>
<div className="space-y-2">
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-blue-500 dark:bg-blue-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.inputTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.inputTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-amber-500 dark:bg-amber-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.outputTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.outputTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-green-500 dark:bg-green-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.cachedTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.cachedTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:trend-up-duotone size-5 text-blue-500 dark:text-blue-400 mr-2" />
使
</h3>
<div className="space-y-4">
{usageStats.byDate.length > 0 ? (
<>
<div>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary">
{usageStats.byDate.length > 0
? formatNumber(usageStats.byDate[usageStats.byDate.length - 1].count)
: '0'}
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<div className="text-sm text-upage-elements-textSecondary mb-2"></div>
<div className="text-sm text-upage-elements-textTertiary">
{usageStats.byDate.length > 1 ? (
(() => {
const current = usageStats.byDate[usageStats.byDate.length - 1].count;
const previous = usageStats.byDate[usageStats.byDate.length - 2].count;
const diff = current - previous;
const percentage = previous !== 0 ? (diff / previous) * 100 : 0;
return (
<div className="flex items-center">
<span className="text-upage-elements-textSecondary">:</span>
<span
className={classNames('ml-1 flex items-center', {
'text-green-500 dark:text-green-400': diff > 0,
'text-red-500 dark:text-red-400': diff < 0,
'text-upage-elements-textTertiary': diff === 0,
})}
>
{diff > 0 ? (
<span className="i-ph:arrow-up size-3.5 mr-0.5"></span>
) : diff < 0 ? (
<span className="i-ph:arrow-down size-3.5 mr-0.5"></span>
) : (
<span className="i-ph:minus size-3.5 mr-0.5"></span>
)}
{Math.abs(percentage).toFixed(0)}%
</span>
</div>
);
})()
) : (
<span></span>
)}
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full py-6">
<span className="text-sm text-upage-elements-textSecondary"></span>
</div>
)}
</div>
</div>
</div>
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
<div className="p-4 border-b border-upage-elements-borderColor">
<h3 className="text-base font-medium text-upage-elements-textPrimary flex items-center">
<span className="i-ph:chart-line-up-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
使
</h3>
</div>
<div className="p-4">
<ChatUsageVisualization usageStats={usageStats} />
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
});

View File

@@ -0,0 +1,372 @@
import { useStore } from '@nanostores/react';
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from 'chart.js';
import classNames from 'classnames';
import { useMemo } from 'react';
import { Doughnut, Line, Pie } from 'react-chartjs-2';
import type { ChatUsageStats } from '~/lib/hooks/useChatUsage';
import { themeStore } from '~/lib/stores/theme';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement);
type ChatUsageVisualizationProps = {
usageStats: ChatUsageStats;
};
export function ChatUsageVisualization({ usageStats }: ChatUsageVisualizationProps) {
const theme = useStore(themeStore);
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
const getThemeColor = (varName: string): string => {
if (typeof document !== 'undefined') {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
}
return isDarkMode ? '#FFFFFF' : '#000000';
};
const chartColors = {
grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
text: getThemeColor('--upage-elements-textPrimary'),
textSecondary: getThemeColor('--upage-elements-textSecondary'),
background: getThemeColor('--upage-elements-bg-depth-1'),
accent: getThemeColor('--upage-elements-button-primary-text'),
border: getThemeColor('--upage-elements-borderColor'),
success: isDarkMode ? 'rgba(34, 197, 94, 0.7)' : 'rgba(34, 197, 94, 0.6)', // 绿色
successBorder: isDarkMode ? 'rgba(34, 197, 94, 0.9)' : 'rgba(34, 197, 94, 0.8)',
failed: isDarkMode ? 'rgba(239, 68, 68, 0.7)' : 'rgba(239, 68, 68, 0.6)', // 红色
failedBorder: isDarkMode ? 'rgba(239, 68, 68, 0.9)' : 'rgba(239, 68, 68, 0.8)',
pending: isDarkMode ? 'rgba(234, 179, 8, 0.7)' : 'rgba(234, 179, 8, 0.6)', // 黄色
pendingBorder: isDarkMode ? 'rgba(234, 179, 8, 0.9)' : 'rgba(234, 179, 8, 0.8)',
aborted: isDarkMode ? 'rgba(107, 114, 128, 0.7)' : 'rgba(107, 114, 128, 0.6)', // 灰色
abortedBorder: isDarkMode ? 'rgba(107, 114, 128, 0.9)' : 'rgba(107, 114, 128, 0.8)',
};
const getChartColors = (index: number) => {
const baseColors = [
{
base: getThemeColor('--upage-elements-button-primary-text'),
},
{
base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)',
},
{
base: getThemeColor('--upage-elements-icon-success'),
},
{
base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)',
},
{
base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)',
},
];
const color = baseColors[index % baseColors.length].base;
let r = 0,
g = 0,
b = 0;
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/);
if (rgbMatch) {
[, r, g, b] = rgbMatch.map(Number);
} else if (rgbaMatch) {
[, r, g, b] = rgbaMatch.map(Number);
} else if (color.startsWith('#')) {
const hex = color.slice(1);
const bigint = parseInt(hex, 16);
r = (bigint >> 16) & 255;
g = (bigint >> 8) & 255;
b = bigint & 255;
}
return {
bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`,
border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`,
};
};
const formatStatus = (status: string) => {
switch (status) {
case 'SUCCESS':
return '成功';
case 'FAILED':
return '失败';
case 'PENDING':
return '处理中';
case 'ABORTED':
return '中止';
default:
return status;
}
};
const getStatusColor = (status: string, isBackground = true) => {
switch (status) {
case 'SUCCESS':
return isBackground ? chartColors.success : chartColors.successBorder;
case 'FAILED':
return isBackground ? chartColors.failed : chartColors.failedBorder;
case 'PENDING':
return isBackground ? chartColors.pending : chartColors.pendingBorder;
case 'ABORTED':
return isBackground ? chartColors.aborted : chartColors.abortedBorder;
default:
return isBackground ? getChartColors(0).bg : getChartColors(0).border;
}
};
const statusDistributionData = {
labels: usageStats.byStatus.map((status) => formatStatus(status.status)),
datasets: [
{
label: '请求状态',
data: usageStats.byStatus.map((status) => status._count),
backgroundColor: usageStats.byStatus.map((status) => getStatusColor(status.status)),
borderColor: usageStats.byStatus.map((status) => getStatusColor(status.status, false)),
borderWidth: 1,
},
],
};
const tokenUsageData = {
labels: ['输入 Token', '输出 Token', '缓存 Token'],
datasets: [
{
label: 'Token 使用量',
data: [
usageStats.total._sum.inputTokens || 0,
usageStats.total._sum.outputTokens || 0,
usageStats.total._sum.cachedTokens || 0,
],
backgroundColor: [getChartColors(1).bg, getChartColors(2).bg, getChartColors(4).bg],
borderColor: [getChartColors(1).border, getChartColors(2).border, getChartColors(4).border],
borderWidth: 1,
},
],
};
const dailyRequestsData = {
labels: usageStats.byDate.map((day) => day.date),
datasets: [
{
label: '每日请求数',
data: usageStats.byDate.map((day) => day.count),
borderColor: getChartColors(4).border,
backgroundColor: 'transparent',
borderWidth: 2,
tension: 0.4, // 添加曲线平滑
pointBackgroundColor: getChartColors(4).border,
pointBorderColor: chartColors.background,
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
fill: false,
},
{
label: '每日 Token 用量',
data: usageStats.byDate.map((day) => day.totalTokens),
borderColor: getChartColors(2).border,
backgroundColor: 'transparent',
borderWidth: 2,
tension: 0.4,
pointBackgroundColor: getChartColors(2).border,
pointBorderColor: chartColors.background,
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
fill: false,
},
],
};
const baseChartOptions = {
responsive: true,
maintainAspectRatio: false,
color: chartColors.text,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.text,
font: {
weight: 'bold' as const,
size: 12,
},
padding: 16,
usePointStyle: true,
},
},
title: {
display: true,
color: chartColors.text,
font: {
size: 16,
weight: 'bold' as const,
},
padding: 16,
},
tooltip: {
titleColor: chartColors.text,
bodyColor: chartColors.text,
backgroundColor: isDarkMode ? 'rgba(23, 23, 23, 0.8)' : 'rgba(255, 255, 255, 0.8)',
borderColor: chartColors.border,
borderWidth: 1,
},
},
};
const statusPieOptions = {
...baseChartOptions,
plugins: {
...baseChartOptions.plugins,
title: {
...baseChartOptions.plugins.title,
text: '请求状态分布',
},
legend: {
...baseChartOptions.plugins.legend,
position: 'right' as const,
},
},
};
const doughnutOptions = {
...baseChartOptions,
plugins: {
...baseChartOptions.plugins,
title: {
...baseChartOptions.plugins.title,
text: 'Token 使用分布',
},
legend: {
...baseChartOptions.plugins.legend,
position: 'right' as const,
},
},
};
const lineChartOptions = {
...baseChartOptions,
plugins: {
...baseChartOptions.plugins,
title: {
...baseChartOptions.plugins.title,
text: '每日请求统计',
},
legend: {
...baseChartOptions.plugins.legend,
onClick: function (_e: any, legendItem: any, legend: any) {
const index = legendItem.datasetIndex;
const ci = legend.chart;
const datasets = ci.data.datasets;
const visibleCount = datasets.reduce((count: number, _dataset: any, i: number) => {
return count + (ci.getDatasetMeta(i).hidden ? 0 : 1);
}, 0);
const meta = ci.getDatasetMeta(index);
const isCurrentlyVisible = !meta.hidden;
if (isCurrentlyVisible && visibleCount === 1) {
meta.hidden = true;
datasets.forEach((_dataset: any, i: number) => {
if (i !== index) {
ci.getDatasetMeta(i).hidden = false;
}
});
} else if (visibleCount === 0) {
datasets.forEach((_dataset: any, i: number) => {
ci.getDatasetMeta(i).hidden = i !== index;
});
} else {
meta.hidden = !meta.hidden;
}
ci.update();
},
},
},
scales: {
x: {
grid: {
color: chartColors.grid,
drawBorder: false,
},
border: {
display: false,
},
ticks: {
color: chartColors.text,
font: {
weight: 500,
},
maxRotation: 45,
minRotation: 45,
},
},
y: {
grid: {
color: chartColors.grid,
drawBorder: false,
},
border: {
display: false,
},
ticks: {
color: chartColors.text,
font: {
weight: 500,
},
},
beginAtZero: true,
},
},
};
const cardClasses = classNames(
'p-6 rounded-lg shadow-sm',
'bg-upage-elements-bg-depth-1',
'border border-upage-elements-borderColor',
);
return (
<div className="space-y-8">
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3"></h3>
<div className="h-64">
<Line data={dailyRequestsData} options={lineChartOptions} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3"></h3>
<div className="h-64">
<Pie data={statusDistributionData} options={statusPieOptions} />
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">Token 使</h3>
<div className="h-64">
<Doughnut data={tokenUsageData} options={doughnutOptions} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,639 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
import classNames from 'classnames';
import { motion, type Transition, type Variants } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { type DeploymentRecord, useDeploymentRecords } from '~/lib/hooks/useDeploymentRecords';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import { ConfirmationDialog, DialogDescription, DialogTitle } from '../../ui/Dialog';
import { IconButton } from '../../ui/IconButton';
import { Tabs, TabsList, TabsTrigger } from '../../ui/Tabs';
const transition: Transition = {
duration: 0.15,
ease: [0.16, 1, 0.3, 1],
};
const backdropVariants: Variants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
};
const dialogVariants: Variants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
};
interface DeploymentRecordsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export const DeploymentRecordsDialog = memo(({ isOpen, onClose }: DeploymentRecordsDialogProps) => {
const {
deploymentRecords,
totals,
stats,
isLoading,
isPlatformLoading,
loadPlatformRecords,
refreshDeploymentRecords,
toggleAccess,
deletePage,
} = useDeploymentRecords();
const [activePlatform, setActivePlatform] = useState<DeploymentPlatformEnum>(DeploymentPlatformEnum._1PANEL);
const [loadedPlatforms, setLoadedPlatforms] = useState<Set<string>>(new Set());
const initialLoadDone = useRef<boolean>(false);
// 确认对话框状态
type ConfirmAction = 'toggle-access' | 'delete';
type ConfirmDialogState = {
isOpen: boolean;
action: ConfirmAction;
recordId: string | null;
platform: string | null;
recordStatus?: string;
};
const [confirmDialogState, setConfirmDialogState] = useState<ConfirmDialogState>({
isOpen: false,
action: 'toggle-access',
recordId: null,
platform: null,
});
const [isConfirmationLoading, setIsConfirmationLoading] = useState(false);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const loadMore = useCallback(() => {
const currentRecords = deploymentRecords[activePlatform] || [];
loadPlatformRecords({ offset: currentRecords.length, platform: activePlatform });
}, [activePlatform, deploymentRecords, loadPlatformRecords]);
const handleTabChange = useCallback(
(value: string) => {
const newPlatform = value as DeploymentPlatformEnum;
setActivePlatform(newPlatform);
if (!loadedPlatforms.has(newPlatform)) {
loadPlatformRecords({ platform: newPlatform });
setLoadedPlatforms((prev) => new Set(prev).add(newPlatform));
}
},
[loadPlatformRecords, loadedPlatforms],
);
useEffect(() => {
if (isOpen && !initialLoadDone.current) {
refreshDeploymentRecords();
setLoadedPlatforms((prev) => new Set(prev).add(activePlatform));
initialLoadDone.current = true;
}
if (!isOpen) {
initialLoadDone.current = false;
}
}, [isOpen, activePlatform, refreshDeploymentRecords]);
const openConfirmDialog = useCallback((action: ConfirmAction, record: DeploymentRecord) => {
setConfirmDialogState({
isOpen: true,
action,
recordId: record.id,
platform: record.platform,
recordStatus: record.status,
});
}, []);
const closeConfirmDialog = useCallback(() => {
setConfirmDialogState((prev) => ({ ...prev, isOpen: false }));
}, []);
const handleConfirmAction = useCallback(async () => {
const { action, recordId, platform } = confirmDialogState;
if (!recordId || !platform) {
return;
}
setIsConfirmationLoading(true);
try {
if (action === 'toggle-access') {
await toggleAccess(recordId, platform);
}
if (action === 'delete') {
await deletePage(recordId, platform);
}
refreshDeploymentRecords();
closeConfirmDialog();
} catch (error) {
console.error('操作失败:', error);
toast.error('操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setIsConfirmationLoading(false);
}
}, [confirmDialogState, toggleAccess, deletePage, refreshDeploymentRecords, closeConfirmDialog]);
const handleToggleAccess = useCallback(
(record: DeploymentRecord) => {
openConfirmDialog('toggle-access', record);
},
[openConfirmDialog],
);
const handleDeletePage = useCallback(
(record: DeploymentRecord) => {
openConfirmDialog('delete', record);
},
[openConfirmDialog],
);
const cardClasses = classNames(
'p-4 rounded-lg shadow-sm',
'bg-upage-elements-bg-depth-1',
'border border-upage-elements-borderColor',
);
const platformIcons = {
[DeploymentPlatformEnum._1PANEL]: 'i-ph:browser',
[DeploymentPlatformEnum.NETLIFY]: 'i-ph:cloud',
[DeploymentPlatformEnum.VERCEL]: 'i-ph:triangle',
};
// 状态配置类型
type StatusConfig = {
text: string;
bgClass: string;
dotClass: string;
icon: string;
};
// 部署状态配置
const deploymentStatusConfig: Record<string, StatusConfig> = {
[DeploymentStatusEnum.SUCCESS]: {
text: '已部署',
bgClass: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
dotClass: 'bg-green-500 dark:bg-green-400',
icon: 'i-carbon:checkmark-filled',
},
[DeploymentStatusEnum.DEPLOYED]: {
text: '已部署',
bgClass: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
dotClass: 'bg-green-500 dark:bg-green-400',
icon: 'i-carbon:checkmark-filled',
},
[DeploymentStatusEnum.PENDING]: {
text: '部署中',
bgClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
dotClass: 'bg-yellow-500 dark:bg-yellow-400',
icon: 'i-carbon:time',
},
[DeploymentStatusEnum.DEPLOYING]: {
text: '部署中',
bgClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
dotClass: 'bg-yellow-500 dark:bg-yellow-400',
icon: 'i-carbon:in-progress',
},
[DeploymentStatusEnum.FAILED]: {
text: '失败',
bgClass: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
dotClass: 'bg-red-500 dark:bg-red-400',
icon: 'i-carbon:close-filled',
},
[DeploymentStatusEnum.INACTIVE]: {
text: '已停用',
bgClass: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
dotClass: 'bg-gray-500 dark:bg-gray-400',
icon: 'i-carbon:pause-filled',
},
};
// 部署状态徽章组件
const DeploymentStatusBadge = ({ status }: { status: string }) => {
// 获取状态配置,如果不存在则使用默认配置
const config = deploymentStatusConfig[status] || {
text: status,
bgClass: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
dotClass: 'bg-gray-500 dark:bg-gray-400',
icon: 'i-carbon:help',
};
return (
<span className={classNames('px-2 py-1 text-xs rounded-full inline-flex items-center gap-1', config.bgClass)}>
<span className={classNames('size-1.5 rounded-full', config.dotClass)} />
{config.text}
</span>
);
};
const isActive = useCallback((status: string) => {
return status === DeploymentStatusEnum.SUCCESS || status === DeploymentStatusEnum.DEPLOYED;
}, []);
return (
<Tooltip.Provider>
<>
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
initial="closed"
animate="open"
exit="closed"
variants={backdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[95vw] max-w-[1000px] max-h-[85vh] flex flex-col',
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<DialogDescription className="sr-only">
访
</DialogDescription>
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
<DialogTitle></DialogTitle>
<div className="flex items-center gap-2">
<IconButton
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
onClick={refreshDeploymentRecords}
disabled={isLoading}
className={classNames(
'text-upage-elements-textTertiary hover:text-upage-elements-textSecondary',
{
'opacity-50 cursor-not-allowed': isLoading,
},
)}
aria-label="刷新统计数据"
title="刷新统计数据"
/>
<RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
/>
</RadixDialog.Close>
</div>
</div>
<div className="flex-1 overflow-auto relative">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 dark:bg-gray-950/70 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm">
<div className="i-ph:spinner-gap-bold animate-spin size-5 text-upage-elements-textTertiary" />
<span className="text-upage-elements-textSecondary font-medium">...</span>
</div>
</div>
)}
<div className="flex-1 overflow-auto p-6">
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:globe-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
{stats.totalSites || 0}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1">访</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:users-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
{stats.totalVisits.toLocaleString()}
</div>
</div>
</div>
<div>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-4 flex items-center">
<span className="i-ph:list-checks-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
</h3>
<Tabs value={activePlatform} onValueChange={handleTabChange} className="mb-4">
<TabsList className="w-full border border-upage-elements-borderColor rounded-md p-1 bg-gray-50 dark:bg-gray-900/20 flex">
{Object.values(DeploymentPlatformEnum).map((platform, index) => {
const count = stats.sitesByPlatform?.[platform] || 0;
const isLoading = isPlatformLoading(platform);
const isActive = activePlatform === platform;
const isLast = index === Object.values(DeploymentPlatformEnum).length - 1;
return (
<div key={platform} className="flex items-center flex-1">
<TabsTrigger
value={platform}
className={classNames(
'flex-1 relative py-2 px-3 transition-all duration-200',
isActive
? 'bg-white dark:bg-gray-800 shadow-sm rounded-md text-upage-elements-textPrimary font-medium'
: 'hover:bg-gray-100/70 dark:hover:bg-gray-800/30 text-upage-elements-textSecondary',
)}
>
<div className="flex items-center justify-center gap-2">
<span
className={classNames(
platformIcons[platform],
'size-4',
isActive ? 'text-purple-500 dark:text-purple-400' : '',
)}
/>
<span>{platform === DeploymentPlatformEnum._1PANEL ? '1Panel' : platform}</span>
{isLoading ? (
<span className="i-carbon:circle-dash animate-spin size-3 ml-1 text-purple-500 dark:text-purple-400" />
) : (
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
{count || 0}
</span>
)}
</div>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-purple-500 dark:bg-purple-400 rounded-full mx-4" />
)}
</TabsTrigger>
{!isLast && (
<div className="h-8 w-px bg-upage-elements-borderColor dark:bg-gray-700/50" />
)}
</div>
);
})}
</TabsList>
</Tabs>
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
<div className="min-h-[400px] max-h-[500px] flex flex-col">
<div className="overflow-x-auto h-full relative">
<table className="w-full table-fixed">
<thead className="bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[10%]">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[25%]">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[20%]">
</th>
</tr>
</thead>
<tbody className="divide-y divide-upage-elements-borderColor">
{isPlatformLoading(activePlatform) && !deploymentRecords[activePlatform]?.length ? (
<tr>
<td colSpan={6} className="h-[300px]">
<div className="flex flex-col items-center justify-center h-full">
<div className="i-ph:spinner-gap-bold animate-spin size-8 mb-2 text-purple-500 dark:text-purple-400" />
<span className="text-upage-elements-textSecondary">...</span>
</div>
</td>
</tr>
) : deploymentRecords[activePlatform]?.length > 0 ? (
deploymentRecords[activePlatform].map((record) => (
<tr
key={record.id}
className="hover:bg-gray-50 dark:hover:bg-gray-900/30 transition-colors duration-150"
>
<td className="px-4 py-3 text-sm">
<a
href={`/chat/${record.chatId}`}
className="text-blue-500 dark:text-blue-400 hover:underline hover:text-purple-700 dark:hover:text-purple-300 transition-colors"
title={record.chat?.description || '未命名聊天'}
>
<div className="flex items-center gap-1">
<span className="i-ph:chat-circle-text size-4 flex-shrink-0" />
<span className="line-clamp-1">
{record.chat?.description || '未命名聊天'}
</span>
</div>
</a>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<DeploymentStatusBadge status={record.status} />
</td>
<td className="px-4 py-3 text-sm text-blue-500 dark:text-blue-400 text-ellipsis text-nowrap">
<a
href={record.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center gap-1"
>
<span className="i-ph:link size-4 flex-shrink-0" />
<span className="line-clamp-1">{record.url}</span>
</a>
</td>
<td className="px-4 py-3 text-sm text-upage-elements-textSecondary whitespace-nowrap">
{formatDate(record.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-upage-elements-textSecondary whitespace-nowrap">
{formatDate(record.updatedAt)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center space-x-2">
<Tooltip.Root>
<Tooltip.Trigger asChild>
<IconButton
icon={isActive(record.status) ? 'i-ph:pause-fill' : 'i-ph:play-fill'}
onClick={() => handleToggleAccess(record)}
className="!text-gray-500 !hover:text-purple-600 dark:!text-gray-400 dark:!hover:text-purple-400"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
sideOffset={5}
side="top"
>
{isActive(record.status) ? '停止访问' : '开启访问'}
<Tooltip.Arrow
className="fill-upage-elements-background-depth-3"
width={12}
height={6}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<IconButton
icon="i-ph:pencil-duotone"
onClick={() => {
window.open(`/chat/${record.chatId}`);
}}
className="!text-gray-500 !hover:text-blue-600 dark:!text-gray-400 dark:!hover:text-blue-400"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
sideOffset={5}
side="top"
>
<Tooltip.Arrow
className="fill-upage-elements-background-depth-3"
width={12}
height={6}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<IconButton
icon={'i-ph:trash-duotone'}
onClick={() => handleDeletePage(record)}
className="!text-gray-500 !hover:text-red-600 dark:!text-gray-400 dark:!hover:text-red-400"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
sideOffset={5}
side="top"
>
<Tooltip.Arrow
className="fill-upage-elements-background-depth-3"
width={12}
height={6}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</div>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={6}
className="px-4 py-8 h-[300px] text-center text-upage-elements-textSecondary"
>
<div className="flex flex-col items-center justify-center h-full">
<div className="i-ph:cloud-slash-duotone size-8 mb-2 opacity-70" />
<span></span>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{deploymentRecords[activePlatform]?.length > 0 &&
deploymentRecords[activePlatform].length < (totals[activePlatform] || 0) && (
<div className="flex justify-center mt-4">
<button
onClick={loadMore}
disabled={isPlatformLoading(activePlatform)}
className={classNames(
'flex items-center gap-2 px-4 py-2 rounded-md text-sm',
'text-upage-elements-textSecondary hover:text-upage-elements-textPrimary',
'border border-upage-elements-borderColor hover:border-upage-elements-borderColorHover',
'transition-colors',
{ 'opacity-50 cursor-not-allowed': isPlatformLoading(activePlatform) },
)}
>
{isPlatformLoading(activePlatform) ? (
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
) : (
<div className="i-ph:arrow-down size-4" />
)}
</button>
</div>
)}
</div>
</div>
</div>
</div>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
<ConfirmationDialog
isOpen={confirmDialogState.isOpen}
onClose={closeConfirmDialog}
onConfirm={handleConfirmAction}
title={
confirmDialogState.action === 'toggle-access'
? `${confirmDialogState.recordStatus === 'inactive' ? '开启' : '停止'}页面访问`
: '删除页面'
}
description={
confirmDialogState.action === 'toggle-access'
? `确定要${confirmDialogState.recordStatus === 'inactive' ? '开启' : '停止'}此页面的访问吗?
${confirmDialogState.recordStatus === 'inactive' ? '开启之后,可能需要等待一段时间才可访问。' : ''}
`
: '确定要删除此页面吗?此操作不可撤销。'
}
confirmLabel={
confirmDialogState.action === 'toggle-access'
? confirmDialogState.recordStatus === 'inactive'
? '开启访问'
: '停止访问'
: '删除页面'
}
cancelLabel="取消"
variant={confirmDialogState.action === 'delete' ? 'destructive' : 'default'}
isLoading={isConfirmationLoading}
/>
</>
</Tooltip.Provider>
);
});