feat(tour):漫游导航
This commit is contained in:
parent
f0d93ab342
commit
eb45bba7ff
|
|
@ -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
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const zhCN: Translations = {
|
|||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: "可直接对话; 或输入需求并选择skill,完成专业任务;",
|
||||
welcomePlaceholder: "可直接对话; 或输入需求并选择skill,完成专业任务;",
|
||||
welcomePlaceholder: "可直接对话; 或输入需求并选择skill,完成专业任务。",
|
||||
chatPlaceholder: "“@”可引用文件。",
|
||||
createSkillPrompt:
|
||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||
|
|
|
|||
Loading…
Reference in New Issue