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",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"antd": "^6.3.6",
"best-effort-json-parser": "^1.2.1",
"better-auth": "^1.3",
"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,
...props
}: 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>;

View File

@ -37,7 +37,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
}
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: {
align: {
@ -46,9 +46,9 @@ const inputGroupAddonVariants = cva(
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"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":
"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: {

View File

@ -1,6 +1,7 @@
"use client";
import type { ChatStatus } from "ai";
import { Tour } from "antd";
import {
CheckIcon,
GraduationCapIcon,
@ -17,6 +18,7 @@ import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
@ -25,6 +27,7 @@ import {
type ChangeEvent,
type KeyboardEvent,
type ComponentProps,
type RefObject,
} from "react";
import { toast } from "sonner";
@ -99,6 +102,25 @@ import { Tooltip } from "./tooltip";
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 = {
key: string;
@ -215,6 +237,10 @@ export function InputBox({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const containerRef = useRef<HTMLDivElement | 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 [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
@ -231,11 +257,97 @@ export function InputBox({
start: number;
end: number;
} | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused =
((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(() => {
@ -615,6 +727,21 @@ export function InputBox({
}}
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
references={references}
threadId={threadId}
@ -784,7 +911,7 @@ export function InputBox({
"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
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@ -795,20 +922,26 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{showWelcomeStyle && (
<div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton
className="px-2!"
router={router}
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
className="px-2!"
skillButtonRef={skillButtonTourRef}
selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill}
/>
</div>
{/* <div className="h-[40px] w-[140px] shrink-0" aria-hidden="true" /> */}
{/* 参考 kexue 版本隐藏运行模式切换按钮 */}
</PromptInputTools>
@ -845,7 +978,7 @@ export function InputBox({
</ModelSelector> */}
<PromptInputTools>
{/* 占位符 */}
<div className="w-[150px]"></div>
<div className="w-[150px] h-[40px]"></div>
</PromptInputTools>
</PromptInputFooter>
<PromptInputSubmit
@ -856,10 +989,9 @@ export function InputBox({
/>
</PromptInput>
{showWelcomeStyle &&
!hasSubmitted &&
searchParams.get("mode") !== "skill" && (
{shouldShowSuggestionList && (
<SuggestionListContainer
ref={suggestionListTourRef}
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
isBootstrapping={iframeSkill.isBootstrapping}
/>
@ -926,25 +1058,29 @@ export function InputBox({
}
// SuggestionList 容器
function SuggestionListContainer({
bootstrapAndLockSkills,
isBootstrapping,
}: {
const SuggestionListContainer = forwardRef<HTMLDivElement, {
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) {
}>(
function SuggestionListContainer(
{ bootstrapAndLockSkills, isBootstrapping },
ref,
) {
return (
<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
bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping}
/>
</div>
</div>
);
}
},
);
// 快速选择skillbutton
function SuggestionList({
@ -1046,8 +1182,8 @@ function AddAttachmentsButton({ className }: { className?: string }) {
const attachments = usePromptInputAttachments();
return (
<Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
<WorkspaceToolButton
className={className}
onClick={() => attachments.openFileDialog()}
>
<svg
@ -1067,7 +1203,7 @@ function AddAttachmentsButton({ className }: { className?: string }) {
stroke="#150033"
/>
</svg>
</PromptInputButton>
</WorkspaceToolButton>
</Tooltip>
);
}
@ -1084,8 +1220,8 @@ function HistoryButton({
const { t } = useI18n();
return (
<Tooltip content={t.inputBox.history}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
<WorkspaceToolButton
className={className}
onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
}
@ -1111,19 +1247,21 @@ function HistoryButton({
strokeLinejoin="round"
/>
</svg>
</PromptInputButton>
</WorkspaceToolButton>
</Tooltip>
);
}
// 启动iframeSkillDialog
function IframeSkillDialogButton({
className,
skillButtonRef,
selectedSkills,
isBootstrapping,
openSkillDialog,
clearSkill,
}: {
className?: string;
skillButtonRef?: RefObject<HTMLDivElement | null>;
selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean;
openSkillDialog: () => void;
@ -1132,10 +1270,11 @@ function IframeSkillDialogButton({
const { t } = useI18n();
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}>
<PromptInputButton
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)}
<div ref={skillButtonRef} className="shrink-0">
<WorkspaceToolButton
className={cn("shrink-0", className)}
onClick={openSkillDialog}
>
<svg
@ -1149,7 +1288,8 @@ function IframeSkillDialogButton({
stroke="#150033"
/>
</svg>
</PromptInputButton>
</WorkspaceToolButton>
</div>
</Tooltip>
{isBootstrapping ? (
<Tag className="bg-background text-muted-foreground gap-2 border">
@ -1159,7 +1299,7 @@ function IframeSkillDialogButton({
) : null}
{!isBootstrapping && selectedSkills.length > 0 ? (
<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) => {
if (event.deltaY === 0) return;
event.currentTarget.scrollLeft += event.deltaY;

View File

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