🎉 first commit
This commit is contained in:
227
app/components/chat/Artifact.tsx
Normal file
227
app/components/chat/Artifact.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/components/chat/AssistantMessage.tsx
Normal file
54
app/components/chat/AssistantMessage.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
55
app/components/chat/BaseChat.module.scss
Normal file
55
app/components/chat/BaseChat.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
158
app/components/chat/Chat.client.tsx
Normal file
158
app/components/chat/Chat.client.tsx
Normal 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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
app/components/chat/ChatAlert.tsx
Normal file
112
app/components/chat/ChatAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
app/components/chat/ChatTextarea.tsx
Normal file
260
app/components/chat/ChatTextarea.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
app/components/chat/CodeBlock.module.scss
Normal file
10
app/components/chat/CodeBlock.module.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.CopyButtonContainer {
|
||||
button:before {
|
||||
content: 'Copied';
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
left: -53px;
|
||||
padding: 2px 6px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
82
app/components/chat/CodeBlock.tsx
Normal file
82
app/components/chat/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
54
app/components/chat/ElementEditPreview.tsx
Normal file
54
app/components/chat/ElementEditPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
app/components/chat/ElementPreview.tsx
Normal file
58
app/components/chat/ElementPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
app/components/chat/ExamplePrompts.tsx
Normal file
36
app/components/chat/ExamplePrompts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/chat/FilePreview.tsx
Normal file
37
app/components/chat/FilePreview.tsx
Normal 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;
|
||||
171
app/components/chat/Markdown.module.scss
Normal file
171
app/components/chat/Markdown.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/components/chat/Markdown.spec.ts
Normal file
48
app/components/chat/Markdown.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
124
app/components/chat/Markdown.tsx
Normal file
124
app/components/chat/Markdown.tsx
Normal 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');
|
||||
};
|
||||
192
app/components/chat/Messages.client.tsx
Normal file
192
app/components/chat/Messages.client.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
57
app/components/chat/Messages.module.scss
Normal file
57
app/components/chat/Messages.module.scss
Normal 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);
|
||||
}
|
||||
315
app/components/chat/ModelSelector.tsx
Normal file
315
app/components/chat/ModelSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
app/components/chat/NetlifyDeploymentLink.client.tsx
Normal file
43
app/components/chat/NetlifyDeploymentLink.client.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
118
app/components/chat/ProgressCompilation.tsx
Normal file
118
app/components/chat/ProgressCompilation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
app/components/chat/ScreenshotStateManager.tsx
Normal file
22
app/components/chat/ScreenshotStateManager.tsx
Normal 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;
|
||||
};
|
||||
43
app/components/chat/SendButton.client.tsx
Normal file
43
app/components/chat/SendButton.client.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
app/components/chat/SpeechRecognition.tsx
Normal file
27
app/components/chat/SpeechRecognition.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
app/components/chat/ThoughtBox.tsx
Normal file
44
app/components/chat/ThoughtBox.tsx
Normal 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;
|
||||
36
app/components/chat/UserMessage.tsx
Normal file
36
app/components/chat/UserMessage.tsx
Normal 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, '');
|
||||
}
|
||||
44
app/components/chat/VercelDeploymentLink.client.tsx
Normal file
44
app/components/chat/VercelDeploymentLink.client.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
44
app/components/chat/_1PanelDeploymentLink.client.tsx
Normal file
44
app/components/chat/_1PanelDeploymentLink.client.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
12
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
12
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
424
app/components/chat/usage/ChatUsageDialog.tsx
Normal file
424
app/components/chat/usage/ChatUsageDialog.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
372
app/components/chat/usage/ChatUsageVisualization.tsx
Normal file
372
app/components/chat/usage/ChatUsageVisualization.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
639
app/components/chat/usage/DeploymentRecordsDialog.tsx
Normal file
639
app/components/chat/usage/DeploymentRecordsDialog.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user