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(() => {
@ -287,9 +399,9 @@ export function InputBox({
isImage: isImageFilename(filename),
previewUrl: threadId
? urlOfArtifact({
filepath: path,
threadId,
})
filepath: path,
threadId,
})
: undefined,
};
});
@ -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}
@ -781,10 +908,10 @@ export function InputBox({
className={cn(
"flex transition-all duration-300 ease-out",
!effectiveIsFocused &&
"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
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@ -795,20 +922,26 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{showWelcomeStyle && (
<HistoryButton
className="px-2!"
router={router}
threadId={threadIdFromProps}
/>
<div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton
router={router}
threadId={threadIdFromProps}
/>
</div>
)}
<AddAttachmentsButton className="px-2!" />
<IframeSkillDialogButton
className="px-2!"
selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill}
/>
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
<AddAttachmentsButton />
</div>
<div className="min-w-0 grow basis-0 h-full">
<IframeSkillDialogButton
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;
}) {
return (
}>(
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,28 +1182,28 @@ function AddAttachmentsButton({ className }: { className?: string }) {
const attachments = usePromptInputAttachments();
return (
<Tooltip content={t.inputBox.addAttachments}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
onClick={() => attachments.openFileDialog()}
<WorkspaceToolButton
className={className}
onClick={() => attachments.openFileDialog()}
>
<svg
width="18"
height="15"
viewBox="0 0 18 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="transition-[stroke] duration-200 [&>path]:transition-[fill,stroke] [&>path]:duration-200 [&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
>
<svg
width="18"
height="15"
viewBox="0 0 18 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="transition-[stroke] duration-200 [&>path]:transition-[fill,stroke] [&>path]:duration-200 [&>path:first-child]:group-hover:fill-[#8E47F0] [&>path:last-child]:group-hover:stroke-[#8E47F0]"
>
<path
d="M7.05042 7.65254C6.9754 7.72756 6.90039 7.80257 6.90039 7.95258C6.90039 8.02759 6.9754 8.1776 7.05042 8.25262C7.20043 8.40263 7.42545 8.40263 7.57546 8.25262L8.8506 6.97747V10.7279C8.8506 10.9529 9.00061 11.1029 9.22563 11.1029C9.30065 11.1029 9.45066 11.0279 9.52567 11.0279C9.60067 10.9529 9.67568 10.8779 9.67568 10.7279V6.97747L10.9508 8.25262C11.1008 8.40263 11.3259 8.40263 11.4759 8.25262C11.5509 8.1776 11.6259 8.10259 11.6259 7.95258C11.6259 7.87757 11.5509 7.72756 11.4759 7.65254L9.52567 5.70235C9.37564 5.55234 9.15062 5.55234 9.00061 5.70235L7.05042 7.65254Z"
fill="#150033"
/>
<path
d="M1.12695 0.5H6.67871C6.87077 0.500077 7.01409 0.574515 7.07324 0.648438L7.09082 0.669922L8.30762 1.88672C8.6222 2.20119 9.01344 2.3681 9.44629 2.36816H16.875C17.2382 2.36842 17.5012 2.63339 17.5 2.99414V13.8848C17.5048 14.2408 17.2454 14.5056 16.8818 14.5059H1.12695C0.764649 14.5057 0.5 14.2401 0.5 13.877V1.12793C0.500049 0.810129 0.702664 0.567404 0.996094 0.511719L1.12695 0.5Z"
stroke="#150033"
/>
</svg>
</PromptInputButton>
<path
d="M7.05042 7.65254C6.9754 7.72756 6.90039 7.80257 6.90039 7.95258C6.90039 8.02759 6.9754 8.1776 7.05042 8.25262C7.20043 8.40263 7.42545 8.40263 7.57546 8.25262L8.8506 6.97747V10.7279C8.8506 10.9529 9.00061 11.1029 9.22563 11.1029C9.30065 11.1029 9.45066 11.0279 9.52567 11.0279C9.60067 10.9529 9.67568 10.8779 9.67568 10.7279V6.97747L10.9508 8.25262C11.1008 8.40263 11.3259 8.40263 11.4759 8.25262C11.5509 8.1776 11.6259 8.10259 11.6259 7.95258C11.6259 7.87757 11.5509 7.72756 11.4759 7.65254L9.52567 5.70235C9.37564 5.55234 9.15062 5.55234 9.00061 5.70235L7.05042 7.65254Z"
fill="#150033"
/>
<path
d="M1.12695 0.5H6.67871C6.87077 0.500077 7.01409 0.574515 7.07324 0.648438L7.09082 0.669922L8.30762 1.88672C8.6222 2.20119 9.01344 2.3681 9.44629 2.36816H16.875C17.2382 2.36842 17.5012 2.63339 17.5 2.99414V13.8848C17.5048 14.2408 17.2454 14.5056 16.8818 14.5059H1.12695C0.764649 14.5057 0.5 14.2401 0.5 13.877V1.12793C0.500049 0.810129 0.702664 0.567404 0.996094 0.511719L1.12695 0.5Z"
stroke="#150033"
/>
</svg>
</WorkspaceToolButton>
</Tooltip>
);
}
@ -1084,46 +1220,48 @@ function HistoryButton({
const { t } = useI18n();
return (
<Tooltip content={t.inputBox.history}>
<PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
}
<WorkspaceToolButton
className={className}
onClick={() =>
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
}
>
<svg
className="transition-[stroke] duration-200"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<svg
className="transition-[stroke] duration-200"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
cx="9"
cy="9"
r="8.5"
/>
<path
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
d="M9 6V10H12"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</PromptInputButton>
<circle
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
cx="9"
cy="9"
r="8.5"
/>
<path
className="stroke-[#150033] transition-[stroke] duration-200 group-hover:stroke-[#8E47F0]"
d="M9 6V10H12"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</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,24 +1270,26 @@ 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)}
onClick={openSkillDialog}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]"
viewBox="0 0 12 16"
fill="none"
<div ref={skillButtonRef} className="shrink-0">
<WorkspaceToolButton
className={cn("shrink-0", className)}
onClick={openSkillDialog}
>
<path
d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z"
stroke="#150033"
/>
</svg>
</PromptInputButton>
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-4 transition-[stroke] duration-200 [&>path]:transition-[stroke] [&>path]:duration-200 [&>path]:group-hover:stroke-[#8E47F0]"
viewBox="0 0 12 16"
fill="none"
>
<path
d="M3.7998 0.5H9.19922C9.24033 0.5 9.26852 0.518136 9.28516 0.541992C9.30124 0.565318 9.30411 0.588767 9.29395 0.613281H9.29297L7.43066 5.07422L7.1416 5.76758H11.3994C11.4295 5.76765 11.4474 5.77552 11.459 5.7832C11.4724 5.79207 11.4846 5.80503 11.4922 5.82129C11.4997 5.83745 11.5013 5.85253 11.5 5.86328C11.4989 5.87156 11.4953 5.88556 11.4785 5.9043L2.87891 15.4629V15.4639C2.85396 15.4914 2.83406 15.4971 2.82031 15.499C2.80144 15.5016 2.77553 15.4981 2.74902 15.4844C2.72225 15.4705 2.70837 15.453 2.70312 15.4424C2.70056 15.4372 2.69457 15.4253 2.70312 15.3936V15.3926L4.30273 9.49512L4.47461 8.86426H0.600586C0.559682 8.86424 0.531324 8.84587 0.514648 8.82227C0.498608 8.79944 0.496551 8.777 0.505859 8.75293L3.70508 0.558594C3.71075 0.544183 3.72173 0.529788 3.73828 0.518555C3.74688 0.51277 3.75704 0.508037 3.76758 0.504883L3.7998 0.5Z"
stroke="#150033"
/>
</svg>
</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;
@ -1227,9 +1367,9 @@ function AttachmentPreviewBar({
const referenceUrl =
threadId && reference.path
? urlOfArtifact({
filepath: reference.path,
threadId,
})
filepath: reference.path,
threadId,
})
: null;
const filename = reference.filename ?? "reference";
const imageMatch = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.exec(filename);

View File

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