feat(tour):漫游导航

This commit is contained in:
肖应宇 2026-04-20 15:31:52 +08:00
parent f0d93ab342
commit eb45bba7ff
6 changed files with 1060 additions and 150 deletions

View File

@ -58,6 +58,7 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.33", "ai": "^6.0.33",
"antd": "^6.3.6",
"best-effort-json-parser": "^1.2.1", "best-effort-json-parser": "^1.2.1",
"better-auth": "^1.3", "better-auth": "^1.3",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",

File diff suppressed because it is too large Load Diff

View File

@ -1064,7 +1064,7 @@ export const PromptInputTools = ({
className, className,
...props ...props
}: PromptInputToolsProps) => ( }: PromptInputToolsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} /> <div className={cn("flex items-center h-full gap-1", className)} {...props} />
); );
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>; export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;

View File

@ -37,7 +37,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", "text-muted-foreground flex h-[58px] cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{ {
variants: { variants: {
align: { align: {
@ -46,9 +46,9 @@ const inputGroupAddonVariants = cva(
"inline-end": "inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start": "block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", "order-first w-full justify-start px-3 pt-5 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end": "block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", "order-last w-full justify-start px-3 py-0 pb-5 group-has-[>input]/input-group:pb-2.5",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { Tour } from "antd";
import { import {
CheckIcon, CheckIcon,
GraduationCapIcon, GraduationCapIcon,
@ -17,6 +18,7 @@ import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { import {
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@ -25,6 +27,7 @@ import {
type ChangeEvent, type ChangeEvent,
type KeyboardEvent, type KeyboardEvent,
type ComponentProps, type ComponentProps,
type RefObject,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -99,6 +102,25 @@ import { Tooltip } from "./tooltip";
const MAX_REFERENCES_PER_MESSAGE = 10; const MAX_REFERENCES_PER_MESSAGE = 10;
const INPUT_TOOLS_TOUR_SEEN_KEY = "workspace.input_tools_tour_seen.v1";
type WorkspaceToolButtonProps = ComponentProps<typeof PromptInputButton>;
function WorkspaceToolButton({
className,
...props
}: WorkspaceToolButtonProps) {
return (
<PromptInputButton
className={cn(
// border border-[rgba(0,0,0,0.08)]
"group h-full p-[10px]! rounded-[10px] hover:bg-[#EAE2F5] hover:text-[#8E47F0]",
className,
)}
{...props}
/>
);
}
type MentionCandidate = { type MentionCandidate = {
key: string; key: string;
@ -215,6 +237,10 @@ export function InputBox({
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mentionTriggerRef = useRef<HTMLButtonElement | null>(null); const mentionTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyButtonTourRef = useRef<HTMLDivElement | null>(null);
const attachmentsButtonTourRef = useRef<HTMLDivElement | null>(null);
const skillButtonTourRef = useRef<HTMLDivElement | null>(null);
const suggestionListTourRef = useRef<HTMLDivElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false);
@ -231,11 +257,97 @@ export function InputBox({
start: number; start: number;
end: number; end: number;
} | null>(null); } | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps); const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息) // isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused = const effectiveIsFocused =
((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused; ((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused;
const shouldShowSuggestionList =
showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill";
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted) {
setIsInputToolsTourReady(false);
return;
}
const frameId = window.requestAnimationFrame(() => {
setIsInputToolsTourReady(
Boolean(
historyButtonTourRef.current &&
attachmentsButtonTourRef.current &&
skillButtonTourRef.current &&
(!shouldShowSuggestionList || suggestionListTourRef.current),
),
);
});
return () => window.cancelAnimationFrame(frameId);
}, [
showWelcomeStyle,
hasSubmitted,
shouldShowSuggestionList,
iframeSkill.isBootstrapping,
iframeSkill.selectedSkills.length,
]);
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) {
setIsInputToolsTourOpen(false);
return;
}
const hasSeenTour = window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY);
if (!hasSeenTour) {
setIsInputToolsTourOpen(true);
}
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady]);
const closeInputToolsTour = useCallback(() => {
window.localStorage.setItem(INPUT_TOOLS_TOUR_SEEN_KEY, "1");
setIsInputToolsTourOpen(false);
}, []);
const inputToolsTourSteps = useMemo(() => {
const baseSteps = [
{
title: "查看历史",
description: "点击这里,可以查看历史会话与文档。",
target: () => historyButtonTourRef.current ?? document.body,
},
{
title: "上传附件",
description: "点击这里,上传参考文档或拟处理的文档。",
target: () => attachmentsButtonTourRef.current ?? document.body,
},
{
title: "选择 Skill",
description: (
<>
skill使skill
<br />
广skill使skill
</>
),
target: () => skillButtonTourRef.current ?? document.body,
},
...(shouldShowSuggestionList
? [
{
title: "试试我吧",
target: () => suggestionListTourRef.current ?? document.body,
},
]
: []),
];
return baseSteps.map((step, index) => ({
...step,
prevButtonProps: { children: "上一步" },
nextButtonProps: {
children: index === baseSteps.length - 1 ? "完成" : "下一步",
},
}));
}, [shouldShowSuggestionList]);
// 点击外部区域时收起输入框 // 点击外部区域时收起输入框
useEffect(() => { useEffect(() => {
@ -615,6 +727,21 @@ export function InputBox({
}} }}
className="relative w-full" className="relative w-full"
> >
<Tour
open={isInputToolsTourOpen}
onClose={closeInputToolsTour}
onFinish={closeInputToolsTour}
gap={
{ offset: 4 , radius: 2 }
}
mask={{
style: {
boxShadow: 'inset 0 0 15px #333',
},
color: 'rgba(255,255,255, .8)',
}}
steps={inputToolsTourSteps}
/>
<AttachmentPreviewBar <AttachmentPreviewBar
references={references} references={references}
threadId={threadId} threadId={threadId}
@ -784,7 +911,7 @@ export function InputBox({
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0", "pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
)} )}
> >
<PromptInputTools className="min-w-0 flex-1 gap-[20px]"> <PromptInputTools className="min-w-0 w-full overflow-hidden gap-[20px]">
{/* TODO: Add more connectors here {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" /> <PromptInputActionMenuTrigger className="px-2!" />
@ -795,20 +922,26 @@ export function InputBox({
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
{showWelcomeStyle && ( {showWelcomeStyle && (
<div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton <HistoryButton
className="px-2!"
router={router} router={router}
threadId={threadIdFromProps} threadId={threadIdFromProps}
/> />
</div>
)} )}
<AddAttachmentsButton className="px-2!" /> <div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
<AddAttachmentsButton />
</div>
<div className="min-w-0 grow basis-0 h-full">
<IframeSkillDialogButton <IframeSkillDialogButton
className="px-2!" skillButtonRef={skillButtonTourRef}
selectedSkills={iframeSkill.selectedSkills} selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping} isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog} openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill} clearSkill={iframeSkill.clearSkill}
/> />
</div>
{/* <div className="h-[40px] w-[140px] shrink-0" aria-hidden="true" /> */}
{/* 参考 kexue 版本隐藏运行模式切换按钮 */} {/* 参考 kexue 版本隐藏运行模式切换按钮 */}
</PromptInputTools> </PromptInputTools>
@ -845,7 +978,7 @@ export function InputBox({
</ModelSelector> */} </ModelSelector> */}
<PromptInputTools> <PromptInputTools>
{/* 占位符 */} {/* 占位符 */}
<div className="w-[150px]"></div> <div className="w-[150px] h-[40px]"></div>
</PromptInputTools> </PromptInputTools>
</PromptInputFooter> </PromptInputFooter>
<PromptInputSubmit <PromptInputSubmit
@ -856,10 +989,9 @@ export function InputBox({
/> />
</PromptInput> </PromptInput>
{showWelcomeStyle && {shouldShowSuggestionList && (
!hasSubmitted &&
searchParams.get("mode") !== "skill" && (
<SuggestionListContainer <SuggestionListContainer
ref={suggestionListTourRef}
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills} bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
isBootstrapping={iframeSkill.isBootstrapping} isBootstrapping={iframeSkill.isBootstrapping}
/> />
@ -926,25 +1058,29 @@ export function InputBox({
} }
// SuggestionList 容器 // SuggestionList 容器
function SuggestionListContainer({ const SuggestionListContainer = forwardRef<HTMLDivElement, {
bootstrapAndLockSkills,
isBootstrapping,
}: {
bootstrapAndLockSkills: (params: { bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[]; selectedSkills: SelectedSkillPayloadItem[];
title: string; title: string;
}) => Promise<boolean>; }) => Promise<boolean>;
isBootstrapping: boolean; isBootstrapping: boolean;
}) { }>(
function SuggestionListContainer(
{ bootstrapAndLockSkills, isBootstrapping },
ref,
) {
return ( return (
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4"> <div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
<div ref={ref} className="w-fit">
<SuggestionList <SuggestionList
bootstrapAndLockSkills={bootstrapAndLockSkills} bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping} isBootstrapping={isBootstrapping}
/> />
</div> </div>
</div>
);
},
); );
}
// 快速选择skillbutton // 快速选择skillbutton
function SuggestionList({ function SuggestionList({
@ -1046,8 +1182,8 @@ function AddAttachmentsButton({ className }: { className?: string }) {
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
return ( return (
<Tooltip content={t.inputBox.addAttachments}> <Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton <WorkspaceToolButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)} className={className}
onClick={() => attachments.openFileDialog()} onClick={() => attachments.openFileDialog()}
> >
<svg <svg
@ -1067,7 +1203,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
stroke="#150033" stroke="#150033"
/> />
</svg> </svg>
</PromptInputButton> </WorkspaceToolButton>
</Tooltip> </Tooltip>
); );
} }
@ -1084,8 +1220,8 @@ function HistoryButton({
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Tooltip content={t.inputBox.history}> <Tooltip content={t.inputBox.history}>
<PromptInputButton <WorkspaceToolButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)} className={className}
onClick={() => onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`) router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
} }
@ -1111,19 +1247,21 @@ function HistoryButton({
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</PromptInputButton> </WorkspaceToolButton>
</Tooltip> </Tooltip>
); );
} }
// 启动iframeSkillDialog // 启动iframeSkillDialog
function IframeSkillDialogButton({ function IframeSkillDialogButton({
className, className,
skillButtonRef,
selectedSkills, selectedSkills,
isBootstrapping, isBootstrapping,
openSkillDialog, openSkillDialog,
clearSkill, clearSkill,
}: { }: {
className?: string; className?: string;
skillButtonRef?: RefObject<HTMLDivElement | null>;
selectedSkills: Array<{ skill_id: string; title: string }>; selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean; isBootstrapping: boolean;
openSkillDialog: () => void; openSkillDialog: () => void;
@ -1132,10 +1270,11 @@ function IframeSkillDialogButton({
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 w-full items-center h-full gap-2">
<Tooltip content={t.inputBox.selectSkill}> <Tooltip content={t.inputBox.selectSkill}>
<PromptInputButton <div ref={skillButtonRef} className="shrink-0">
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)} <WorkspaceToolButton
className={cn("shrink-0", className)}
onClick={openSkillDialog} onClick={openSkillDialog}
> >
<svg <svg
@ -1149,7 +1288,8 @@ function IframeSkillDialogButton({
stroke="#150033" stroke="#150033"
/> />
</svg> </svg>
</PromptInputButton> </WorkspaceToolButton>
</div>
</Tooltip> </Tooltip>
{isBootstrapping ? ( {isBootstrapping ? (
<Tag className="bg-background text-muted-foreground gap-2 border"> <Tag className="bg-background text-muted-foreground gap-2 border">
@ -1159,7 +1299,7 @@ function IframeSkillDialogButton({
) : null} ) : null}
{!isBootstrapping && selectedSkills.length > 0 ? ( {!isBootstrapping && selectedSkills.length > 0 ? (
<div <div
className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="flex min-w-0 grow basis-0 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onWheel={(event) => { onWheel={(event) => {
if (event.deltaY === 0) return; if (event.deltaY === 0) return;
event.currentTarget.scrollLeft += event.deltaY; event.currentTarget.scrollLeft += event.deltaY;

View File

@ -79,7 +79,7 @@ export const zhCN: Translations = {
// Input Box // Input Box
inputBox: { inputBox: {
placeholder: "可直接对话; 或输入需求并选择skill完成专业任务;", placeholder: "可直接对话; 或输入需求并选择skill完成专业任务;",
welcomePlaceholder: "可直接对话; 或输入需求并选择skill完成专业任务;", welcomePlaceholder: "可直接对话; 或输入需求并选择skill完成专业任务",
chatPlaceholder: "“@”可引用文件。", chatPlaceholder: "“@”可引用文件。",
createSkillPrompt: createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。", "我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",