1523 lines
49 KiB
TypeScript
1523 lines
49 KiB
TypeScript
"use client";
|
||
|
||
import type { ChatStatus } from "ai";
|
||
import { Tour } from "antd";
|
||
import {
|
||
CheckIcon,
|
||
GraduationCapIcon,
|
||
LightbulbIcon,
|
||
Loader2Icon,
|
||
PaperclipIcon,
|
||
PlusIcon,
|
||
SparklesIcon,
|
||
RocketIcon,
|
||
XIcon,
|
||
ZapIcon,
|
||
} from "lucide-react";
|
||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||
import { useRouter } from "next/navigation";
|
||
import { useSearchParams } from "next/navigation";
|
||
import {
|
||
forwardRef,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type KeyboardEvent,
|
||
type ComponentProps,
|
||
type RefObject,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import {
|
||
PromptInput,
|
||
PromptInputActionMenu,
|
||
PromptInputActionMenuContent,
|
||
PromptInputActionMenuItem,
|
||
PromptInputActionMenuTrigger,
|
||
PromptInputAttachment,
|
||
PromptInputAttachments,
|
||
PromptInputBody,
|
||
PromptInputButton,
|
||
PromptInputFooter,
|
||
PromptInputSubmit,
|
||
PromptInputTextarea,
|
||
PromptInputTools,
|
||
usePromptInputAttachments,
|
||
usePromptInputController,
|
||
type PromptInputMessage,
|
||
type PromptInputReference,
|
||
} from "@/components/ai-elements/prompt-input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { ConfettiButton } from "@/components/ui/confetti-button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuGroup,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { Tag } from "@/components/ui/tag";
|
||
import { useReferenceFiles } from "@/core/artifacts/references";
|
||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
|
||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||
import { useModels } from "@/core/models/hooks";
|
||
import type { AgentThreadContext } from "@/core/threads";
|
||
import {
|
||
MENTION_REFERENCE_EVENT,
|
||
type MentionReferenceEventDetail,
|
||
} from "@/core/threads/reference-events";
|
||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import {
|
||
ModelSelector,
|
||
ModelSelectorContent,
|
||
ModelSelectorInput,
|
||
ModelSelectorItem,
|
||
ModelSelectorList,
|
||
ModelSelectorName,
|
||
ModelSelectorTrigger,
|
||
} from "../ai-elements/model-selector";
|
||
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
|
||
import { ScrollArea } from "../ui/scroll-area";
|
||
|
||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||
import { Tooltip } from "./tooltip";
|
||
|
||
|
||
const MAX_REFERENCES_PER_MESSAGE = 10;
|
||
const INPUT_TOOLS_TOUR_SEEN_KEY = "workspace.input_tools_tour_seen.v1";
|
||
type InputToolsTourSeenState = {
|
||
seen: boolean;
|
||
threadIds?: string[];
|
||
};
|
||
|
||
function parseInputToolsTourSeenState(
|
||
value: string | null,
|
||
): InputToolsTourSeenState | null {
|
||
if (!value) return null;
|
||
if (value === "1") {
|
||
return { seen: true };
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(value) as InputToolsTourSeenState & {
|
||
threadId?: string;
|
||
};
|
||
if (typeof parsed?.seen !== "boolean") {
|
||
return null;
|
||
}
|
||
if (
|
||
parsed.threadIds != null &&
|
||
(!Array.isArray(parsed.threadIds) ||
|
||
parsed.threadIds.some((id) => typeof id !== "string"))
|
||
) {
|
||
return null;
|
||
}
|
||
return {
|
||
seen: parsed.seen,
|
||
threadIds:
|
||
parsed.threadIds ??
|
||
(typeof parsed.threadId === "string" ? [parsed.threadId] : undefined),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type WorkspaceToolButtonProps = ComponentProps<typeof PromptInputButton>;
|
||
|
||
function WorkspaceToolButton({
|
||
className,
|
||
...props
|
||
}: WorkspaceToolButtonProps) {
|
||
return (
|
||
<PromptInputButton
|
||
className={cn(
|
||
"group h-full rounded-[10px] p-[10px]! hover:bg-ws-surface-subtle hover:text-ws-interactive-primary",
|
||
className,
|
||
)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
type MentionCandidate = {
|
||
key: string;
|
||
filename: string;
|
||
path?: string;
|
||
pathTail: string;
|
||
ref_source: "artifact" | "upload";
|
||
ref_kind: "mention";
|
||
typeLabel: string;
|
||
isImage: boolean;
|
||
previewUrl?: string;
|
||
};
|
||
|
||
const IMAGE_EXTENSIONS = new Set([
|
||
"jpg",
|
||
"jpeg",
|
||
"png",
|
||
"webp",
|
||
"gif",
|
||
"bmp",
|
||
"svg",
|
||
"avif",
|
||
]);
|
||
|
||
function isImageFilename(filename: string): boolean {
|
||
const parts = filename.toLowerCase().split(".");
|
||
if (parts.length < 2) return false;
|
||
return IMAGE_EXTENSIONS.has(parts[parts.length - 1] ?? "");
|
||
}
|
||
|
||
function fileExtensionLabel(filename: string): string {
|
||
const parts = filename.split(".");
|
||
if (parts.length < 2) return "FILE";
|
||
return (parts[parts.length - 1] ?? "FILE").toUpperCase().slice(0, 4);
|
||
}
|
||
|
||
function getPathTail(path: string | undefined): string {
|
||
if (!path) return "";
|
||
const segments = path.split("/").filter(Boolean);
|
||
return segments.slice(-2).join("/");
|
||
}
|
||
|
||
function findMentionToken(text: string, caret: number) {
|
||
const uptoCaret = text.slice(0, caret);
|
||
const atIndex = uptoCaret.lastIndexOf("@");
|
||
if (atIndex < 0) {
|
||
return null;
|
||
}
|
||
const query = uptoCaret.slice(atIndex + 1);
|
||
if (/\s/.test(query)) {
|
||
return null;
|
||
}
|
||
return { query, start: atIndex, end: caret };
|
||
}
|
||
|
||
export function InputBox({
|
||
className,
|
||
threadId: threadIdFromProps,
|
||
disabled,
|
||
autoFocus,
|
||
status,
|
||
context,
|
||
extraHeader,
|
||
showWelcomeStyle,
|
||
hasSubmitted,
|
||
initialValue,
|
||
onContextChange,
|
||
onSubmit,
|
||
onStop,
|
||
...props
|
||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||
assistantId?: string | null;
|
||
threadId: string;
|
||
status?: ChatStatus;
|
||
disabled?: boolean;
|
||
context: Omit<
|
||
AgentThreadContext,
|
||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||
> & {
|
||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||
};
|
||
extraHeader?: React.ReactNode;
|
||
showWelcomeStyle: boolean;
|
||
hasSubmitted?: boolean;
|
||
initialValue?: string;
|
||
onContextChange?: (
|
||
context: Omit<
|
||
AgentThreadContext,
|
||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||
> & {
|
||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||
},
|
||
) => void;
|
||
onSubmit?: (message: PromptInputMessage) => void;
|
||
onStop?: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const referenceSourceLabels = useMemo(
|
||
() => ({
|
||
artifact: t.inputBox.referenceSourceArtifact,
|
||
upload: t.inputBox.referenceSourceUpload,
|
||
}),
|
||
[t],
|
||
);
|
||
const searchParams = useSearchParams();
|
||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
||
const router = useRouter();
|
||
const threadId = threadIdFromProps;
|
||
const { textInput } = usePromptInputController();
|
||
const attachments = usePromptInputAttachments();
|
||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||
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);
|
||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [isFocused, setIsFocused] = useState(false);
|
||
const [references, setReferences] = useState<PromptInputReference[]>([]);
|
||
const [mentionQuery, setMentionQuery] = useState("");
|
||
const [mentionOpen, setMentionOpen] = useState(false);
|
||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||
const [mentionRange, setMentionRange] = useState<{
|
||
start: number;
|
||
end: number;
|
||
} | null>(null);
|
||
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
|
||
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
|
||
const { data: referenceFilesData } = useReferenceFiles(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 seenState = parseInputToolsTourSeenState(
|
||
window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY),
|
||
);
|
||
const hasSeenTourForCurrentThread =
|
||
seenState?.seen === true && Boolean(seenState.threadIds?.includes(threadId));
|
||
if (!hasSeenTourForCurrentThread) {
|
||
setIsInputToolsTourOpen(true);
|
||
}
|
||
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady, threadId]);
|
||
|
||
const finishInputToolsTour = useCallback(() => {
|
||
const seenState = parseInputToolsTourSeenState(
|
||
window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY),
|
||
);
|
||
const seenThreadIds = new Set(seenState?.threadIds ?? []);
|
||
seenThreadIds.add(threadId);
|
||
window.localStorage.setItem(
|
||
INPUT_TOOLS_TOUR_SEEN_KEY,
|
||
JSON.stringify({
|
||
seen: true,
|
||
threadIds: Array.from(seenThreadIds),
|
||
} satisfies InputToolsTourSeenState),
|
||
);
|
||
setIsInputToolsTourOpen(false);
|
||
}, [threadId]);
|
||
const closeInputToolsTour = useCallback(() => {
|
||
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(() => {
|
||
if (!isFocused) return;
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (
|
||
containerRef.current &&
|
||
!containerRef.current.contains(event.target as Node)
|
||
) {
|
||
setIsFocused(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isFocused]);
|
||
|
||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||
const { models } = useModels();
|
||
const selectedModel = useMemo(() => {
|
||
if (!context.model_name && models.length > 0) {
|
||
const model = models[0]!;
|
||
setTimeout(() => {
|
||
onContextChange?.({
|
||
...context,
|
||
model_name: model.name,
|
||
mode: model.supports_thinking ? "pro" : "flash",
|
||
});
|
||
}, 0);
|
||
return model;
|
||
}
|
||
return models.find((m) => m.name === context.model_name);
|
||
}, [context, models, onContextChange]);
|
||
const supportThinking = useMemo(
|
||
() => selectedModel?.supports_thinking ?? false,
|
||
[selectedModel],
|
||
);
|
||
|
||
const mentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||
const deduped = new Map<string, MentionCandidate>();
|
||
(referenceFilesData?.files ?? []).forEach((file) => {
|
||
const path = file.virtual_path || "";
|
||
const filename = file.filename ?? path.split("/").pop() ?? path;
|
||
const refSource = file.source === "upload" ? "upload" : "artifact";
|
||
const typeLabel =
|
||
refSource === "upload"
|
||
? referenceSourceLabels.upload
|
||
: referenceSourceLabels.artifact;
|
||
const previewUrl =
|
||
file.artifact_url ||
|
||
(threadId
|
||
? urlOfArtifact({
|
||
filepath: path,
|
||
threadId,
|
||
})
|
||
: undefined);
|
||
|
||
deduped.set(`${refSource}:${path || filename}`, {
|
||
key: `${refSource}:${path || filename}`,
|
||
filename,
|
||
path,
|
||
pathTail: getPathTail(path),
|
||
ref_source: refSource,
|
||
ref_kind: "mention",
|
||
typeLabel,
|
||
isImage: isImageFilename(filename),
|
||
previewUrl,
|
||
});
|
||
});
|
||
return [...deduped.values()];
|
||
}, [
|
||
referenceFilesData?.files,
|
||
referenceSourceLabels.artifact,
|
||
referenceSourceLabels.upload,
|
||
threadId,
|
||
]);
|
||
|
||
const filteredMentionCandidates = useMemo(() => {
|
||
const query = mentionQuery.trim().toLowerCase();
|
||
if (!query) {
|
||
return mentionCandidates;
|
||
}
|
||
return mentionCandidates.filter((candidate) =>
|
||
`${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
|
||
.toLowerCase()
|
||
.includes(query),
|
||
);
|
||
}, [mentionCandidates, mentionQuery]);
|
||
const handleModelSelect = useCallback(
|
||
(model_name: string) => {
|
||
onContextChange?.({
|
||
...context,
|
||
model_name,
|
||
});
|
||
setModelDialogOpen(false);
|
||
},
|
||
[onContextChange, context],
|
||
);
|
||
const handleModeSelect = useCallback(
|
||
(mode: "flash" | "thinking" | "pro" | "ultra") => {
|
||
onContextChange?.({
|
||
...context,
|
||
mode,
|
||
});
|
||
},
|
||
[onContextChange, context],
|
||
);
|
||
const handleSubmit = useCallback(
|
||
async (message: PromptInputMessage) => {
|
||
if (status === "streaming") {
|
||
onStop?.();
|
||
return;
|
||
}
|
||
if (!message.text && references.length === 0) {
|
||
return;
|
||
}
|
||
setIsFocused(false);
|
||
if (showWelcomeStyle) {
|
||
sendToParent({
|
||
type: POST_MESSAGE_TYPES.IS_CHATTING,
|
||
isChatting: true,
|
||
});
|
||
}
|
||
onSubmit?.({
|
||
...message,
|
||
references,
|
||
selectedSkills: iframeSkill.selectedSkills,
|
||
});
|
||
setReferences([]);
|
||
},
|
||
[
|
||
showWelcomeStyle,
|
||
onSubmit,
|
||
onStop,
|
||
references,
|
||
status,
|
||
iframeSkill.selectedSkills,
|
||
],
|
||
);
|
||
|
||
const requestFormSubmit = useCallback(() => {
|
||
const form = promptRootRef.current?.querySelector("form");
|
||
form?.requestSubmit();
|
||
}, []);
|
||
|
||
const addMentionReference = useCallback(
|
||
(reference: PromptInputReference) => {
|
||
setReferences((prev) => {
|
||
const exists = prev.some(
|
||
(item) =>
|
||
item.ref_source === reference.ref_source &&
|
||
item.path === reference.path &&
|
||
item.filename === reference.filename,
|
||
);
|
||
if (exists) {
|
||
return prev;
|
||
}
|
||
if (prev.length >= MAX_REFERENCES_PER_MESSAGE) {
|
||
toast.error(t.inputBox.maxReferencesReached);
|
||
return prev;
|
||
}
|
||
return prev.concat(reference);
|
||
});
|
||
},
|
||
[t.inputBox.maxReferencesReached],
|
||
);
|
||
|
||
const selectMentionCandidate = useCallback(
|
||
(candidate: MentionCandidate) => {
|
||
addMentionReference({
|
||
filename: candidate.filename,
|
||
path: candidate.path,
|
||
ref_kind: "mention",
|
||
ref_source: candidate.ref_source,
|
||
});
|
||
|
||
const current = textInput.value ?? "";
|
||
const range = mentionRange ?? findMentionToken(current, current.length);
|
||
if (range) {
|
||
const before = current.slice(0, range.start);
|
||
const after = current.slice(range.end);
|
||
const nextInput = `${before}${after}`;
|
||
textInput.setInput(nextInput);
|
||
requestAnimationFrame(() => {
|
||
textareaRef.current?.focus();
|
||
textareaRef.current?.setSelectionRange(before.length, before.length);
|
||
});
|
||
}
|
||
setMentionQuery("");
|
||
setMentionOpen(false);
|
||
setActiveMentionIndex(0);
|
||
setMentionRange(null);
|
||
setIsFocused(true);
|
||
},
|
||
[addMentionReference, mentionRange, textInput],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const onMentionReference = (event: Event) => {
|
||
const detail = (event as CustomEvent<MentionReferenceEventDetail>).detail;
|
||
if (detail?.threadId !== threadIdFromProps) {
|
||
return;
|
||
}
|
||
addMentionReference({
|
||
filename: detail.filename,
|
||
path: detail.path,
|
||
ref_kind: "mention",
|
||
ref_source: detail.ref_source,
|
||
});
|
||
setIsFocused(true);
|
||
requestAnimationFrame(() => {
|
||
textareaRef.current?.focus();
|
||
});
|
||
};
|
||
|
||
window.addEventListener(MENTION_REFERENCE_EVENT, onMentionReference);
|
||
return () => {
|
||
window.removeEventListener(MENTION_REFERENCE_EVENT, onMentionReference);
|
||
};
|
||
}, [addMentionReference, threadIdFromProps]);
|
||
|
||
const handleTextareaChange = useCallback(
|
||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||
const value = event.currentTarget.value;
|
||
const caret = event.currentTarget.selectionStart ?? value.length;
|
||
const token = findMentionToken(value, caret);
|
||
if (!token) {
|
||
setMentionOpen(false);
|
||
setMentionQuery("");
|
||
setActiveMentionIndex(0);
|
||
setMentionRange(null);
|
||
return;
|
||
}
|
||
setMentionQuery(token.query);
|
||
setMentionRange({ start: token.start, end: token.end });
|
||
setMentionOpen(true);
|
||
setActiveMentionIndex(0);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleTextareaKeyDown = useCallback(
|
||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (event.nativeEvent.isComposing) {
|
||
return;
|
||
}
|
||
if (
|
||
event.key === "Backspace" &&
|
||
event.currentTarget.value === "" &&
|
||
references.length > 0
|
||
) {
|
||
event.preventDefault();
|
||
setReferences((prev) => prev.slice(0, -1));
|
||
return;
|
||
}
|
||
if (!mentionOpen || filteredMentionCandidates.length === 0) {
|
||
return;
|
||
}
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
setActiveMentionIndex(
|
||
(prev) => (prev + 1) % filteredMentionCandidates.length,
|
||
);
|
||
} else if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
setActiveMentionIndex(
|
||
(prev) =>
|
||
(prev - 1 + filteredMentionCandidates.length) %
|
||
filteredMentionCandidates.length,
|
||
);
|
||
} else if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
const selected = filteredMentionCandidates[activeMentionIndex];
|
||
if (selected) {
|
||
selectMentionCandidate(selected);
|
||
}
|
||
} else if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setMentionOpen(false);
|
||
setMentionRange(null);
|
||
}
|
||
},
|
||
[
|
||
activeMentionIndex,
|
||
filteredMentionCandidates,
|
||
mentionOpen,
|
||
references.length,
|
||
selectMentionCandidate,
|
||
],
|
||
);
|
||
|
||
const handleFollowupClick = useCallback(
|
||
(suggestion: string) => {
|
||
if (status === "streaming") return;
|
||
const current = (textInput?.value ?? "").trim();
|
||
if (current) {
|
||
setPendingSuggestion(suggestion);
|
||
setConfirmOpen(true);
|
||
return;
|
||
}
|
||
textInput?.setInput(suggestion);
|
||
setFollowupsHidden(true);
|
||
setTimeout(() => requestFormSubmit(), 0);
|
||
},
|
||
[requestFormSubmit, status, textInput],
|
||
);
|
||
|
||
const confirmReplaceAndSend = useCallback(() => {
|
||
if (!pendingSuggestion) return;
|
||
textInput?.setInput(pendingSuggestion);
|
||
setFollowupsHidden(true);
|
||
setConfirmOpen(false);
|
||
setTimeout(() => requestFormSubmit(), 0);
|
||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||
|
||
const confirmAppendAndSend = useCallback(() => {
|
||
if (!pendingSuggestion) return;
|
||
const current = (textInput?.value ?? "").trim();
|
||
textInput?.setInput(
|
||
current ? `${current}\n${pendingSuggestion}` : pendingSuggestion,
|
||
);
|
||
setFollowupsHidden(true);
|
||
setConfirmOpen(false);
|
||
setTimeout(() => requestFormSubmit(), 0);
|
||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||
|
||
useEffect(() => {
|
||
if (mentionOpen) {
|
||
mentionTriggerRef.current?.focus();
|
||
}
|
||
}, [mentionOpen]);
|
||
|
||
useEffect(() => {
|
||
/*
|
||
// 暂时禁用 suggestions 接口(/api/threads/{thread_id}/suggestions)
|
||
if (!threadId || isNewThread || disabled) return;
|
||
const controller = new AbortController();
|
||
setFollowupsHidden(false);
|
||
setFollowupsLoading(true);
|
||
setFollowups([]);
|
||
|
||
fetch(`/api/threads/${String(threadId)}/suggestions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ messages: [], n: 3 }),
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (res) => {
|
||
if (!res.ok) return { suggestions: [] };
|
||
return await res.json();
|
||
})
|
||
.then((data) => {
|
||
const suggestions = (data.suggestions ?? [])
|
||
.filter(Boolean)
|
||
.slice(0, 5);
|
||
setFollowups(suggestions);
|
||
})
|
||
.catch(() => setFollowups([]))
|
||
.finally(() => setFollowupsLoading(false));
|
||
|
||
return () => controller.abort();
|
||
*/
|
||
}, [disabled, showWelcomeStyle, threadId]);
|
||
|
||
return (
|
||
<div
|
||
ref={(el) => {
|
||
promptRootRef.current = el;
|
||
containerRef.current = el;
|
||
}}
|
||
className="relative w-full"
|
||
>
|
||
<Tour
|
||
open={isInputToolsTourOpen}
|
||
onClose={closeInputToolsTour}
|
||
onFinish={finishInputToolsTour}
|
||
rootClassName="workspace-input-tools-tour"
|
||
gap={
|
||
{ offset: 3 , radius:10 }
|
||
}
|
||
mask={{
|
||
color: 'rgba(255,255,255, .8)',
|
||
}}
|
||
steps={inputToolsTourSteps}
|
||
/>
|
||
<AttachmentPreviewBar
|
||
references={references}
|
||
threadId={threadId}
|
||
onRemoveReference={(reference) =>
|
||
setReferences((prev) =>
|
||
prev.filter(
|
||
(item) =>
|
||
!(
|
||
item.ref_source === reference.ref_source &&
|
||
item.path === reference.path &&
|
||
item.filename === reference.filename
|
||
),
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
|
||
{extraHeader && (
|
||
<ExtraHeaderContainer
|
||
hasAttachments={attachments.files.length > 0 || references.length > 0}
|
||
>
|
||
{extraHeader}
|
||
</ExtraHeaderContainer>
|
||
)}
|
||
|
||
<PromptInput
|
||
className={cn(
|
||
"bg-background w-full rounded-2xl transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
|
||
className,
|
||
)}
|
||
inputGroupClassName={cn(
|
||
"border-0 rounded-[20px] backdrop-blur-sm",
|
||
"transition-[height] duration-300 ease-out shadow-none ",
|
||
!showWelcomeStyle && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
|
||
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
||
)}
|
||
disabled={isInputDisabled}
|
||
globalDrop
|
||
multiple
|
||
onSubmit={handleSubmit}
|
||
{...props}
|
||
>
|
||
<PromptInputBody
|
||
className={cn(
|
||
"relative transition-[opacity,transform] duration-300 ease-out",
|
||
)}
|
||
>
|
||
<PromptInputTextarea
|
||
ref={textareaRef}
|
||
className={cn(
|
||
"size-full",
|
||
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
||
)}
|
||
disabled={isInputDisabled}
|
||
placeholder={
|
||
showWelcomeStyle
|
||
? t.inputBox.welcomePlaceholder
|
||
: t.inputBox.chatPlaceholder
|
||
}
|
||
autoFocus={autoFocus}
|
||
defaultValue={initialValue}
|
||
onFocus={() => setIsFocused(true)}
|
||
onChange={handleTextareaChange}
|
||
onKeyDown={handleTextareaKeyDown}
|
||
/>
|
||
<DropdownMenu
|
||
modal={false}
|
||
open={mentionOpen && filteredMentionCandidates.length > 0}
|
||
onOpenChange={(open) => {
|
||
setMentionOpen(open);
|
||
if (!open) {
|
||
setMentionRange(null);
|
||
}
|
||
}}
|
||
>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
ref={mentionTriggerRef}
|
||
type="button"
|
||
className="pointer-events-none absolute right-2 bottom-0 h-0 w-0 opacity-0"
|
||
aria-hidden="true"
|
||
tabIndex={-1}
|
||
/>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
side="top"
|
||
sideOffset={8}
|
||
className="max-h-[400px] w-[min(32rem,var(--radix-dropdown-menu-trigger-width)+28rem)] overflow-y-hidden p-[20px]"
|
||
data-testid="mention-candidate-panel"
|
||
onCloseAutoFocus={(event) => {
|
||
event.preventDefault();
|
||
textareaRef.current?.focus();
|
||
}}
|
||
>
|
||
<DropdownMenuLabel className="p-0 text-sm text-ws-fg-primary">
|
||
{t.inputBox.addReference}
|
||
</DropdownMenuLabel>
|
||
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
|
||
<DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0">
|
||
<ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}>
|
||
{filteredMentionCandidates.map((candidate, index) => {
|
||
const detail = [candidate.typeLabel, candidate.pathTail]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return (
|
||
<DropdownMenuItem
|
||
key={candidate.key}
|
||
className={cn(
|
||
"flex items-center justify-between gap-3 rounded-md px-2 py-2 text-left",
|
||
index === activeMentionIndex && "bg-accent",
|
||
)}
|
||
data-active={
|
||
index === activeMentionIndex ? "true" : "false"
|
||
}
|
||
data-candidate-key={candidate.key}
|
||
data-testid="mention-candidate-item"
|
||
aria-label={`${candidate.filename} ${candidate.typeLabel}${candidate.pathTail ? ` ${candidate.pathTail}` : ""}`}
|
||
onFocus={() => setActiveMentionIndex(index)}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
selectMentionCandidate(candidate);
|
||
}}
|
||
>
|
||
{candidate.isImage && candidate.previewUrl ? (
|
||
<img
|
||
src={candidate.previewUrl}
|
||
alt={candidate.filename}
|
||
className="h-10 w-10 shrink-0 rounded-md border object-cover object-top"
|
||
/>
|
||
) : (
|
||
<div className="bg-muted text-muted-foreground flex h-10 w-10 shrink-0 items-center justify-center rounded-md border text-xs font-semibold">
|
||
{fileExtensionLabel(candidate.filename)}
|
||
</div>
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-medium">
|
||
{candidate.filename}
|
||
</span>
|
||
<span className="text-muted-foreground block truncate text-xs">
|
||
{detail}
|
||
</span>
|
||
</div>
|
||
</DropdownMenuItem>
|
||
);
|
||
})}
|
||
</ScrollArea>
|
||
</DropdownMenuGroup>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</PromptInputBody>
|
||
{!effectiveIsFocused && (
|
||
<div
|
||
className="absolute inset-0 z-1 cursor-text"
|
||
onClick={() => {
|
||
setIsFocused(true);
|
||
textareaRef.current?.focus();
|
||
}}
|
||
/>
|
||
)}
|
||
<PromptInputFooter
|
||
className={cn(
|
||
"flex transition-all duration-300 ease-out",
|
||
!effectiveIsFocused &&
|
||
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
|
||
)}
|
||
>
|
||
<PromptInputTools className="min-w-0 w-full overflow-hidden gap-[20px]">
|
||
{/* TODO: Add more connectors here
|
||
<PromptInputActionMenu>
|
||
<PromptInputActionMenuTrigger className="px-2!" />
|
||
<PromptInputActionMenuContent>
|
||
<PromptInputActionAddAttachments
|
||
label={t.inputBox.addAttachments}
|
||
/>
|
||
</PromptInputActionMenuContent>
|
||
</PromptInputActionMenu> */}
|
||
{showWelcomeStyle && (
|
||
<div ref={historyButtonTourRef} className="shrink-0 h-full">
|
||
<HistoryButton
|
||
router={router}
|
||
threadId={threadIdFromProps}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!showWelcomeStyle && (
|
||
<div className="shrink-0 h-full">
|
||
<ExitChattingButton
|
||
router={router}
|
||
threadId={threadIdFromProps}
|
||
/>
|
||
</div>
|
||
)}
|
||
<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>
|
||
{/* <ModelSelector
|
||
open={modelDialogOpen}
|
||
onOpenChange={setModelDialogOpen}
|
||
>
|
||
<ModelSelectorTrigger asChild>
|
||
<PromptInputButton>
|
||
<ModelSelectorName className="text-xs font-normal">
|
||
{selectedModel?.display_name}
|
||
</ModelSelectorName>
|
||
</PromptInputButton>
|
||
</ModelSelectorTrigger>
|
||
<ModelSelectorContent>
|
||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||
<ModelSelectorList>
|
||
{models.map((m) => (
|
||
<ModelSelectorItem
|
||
key={m.name}
|
||
value={m.name}
|
||
onSelect={() => handleModelSelect(m.name)}
|
||
>
|
||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||
{m.name === context.model_name ? (
|
||
<CheckIcon className="ml-auto size-4" />
|
||
) : (
|
||
<div className="ml-auto size-4" />
|
||
)}
|
||
</ModelSelectorItem>
|
||
))}
|
||
</ModelSelectorList>
|
||
</ModelSelectorContent>
|
||
</ModelSelector> */}
|
||
<PromptInputTools>
|
||
{/* 占位符 */}
|
||
<div className="w-[150px] h-[40px]"></div>
|
||
</PromptInputTools>
|
||
</PromptInputFooter>
|
||
<PromptInputSubmit
|
||
className="absolute right-3 bottom-5 z-[20] border-0"
|
||
disabled={isInputDisabled}
|
||
variant="outline"
|
||
status={status}
|
||
/>
|
||
</PromptInput>
|
||
|
||
{shouldShowSuggestionList && (
|
||
<SuggestionListContainer
|
||
ref={suggestionListTourRef}
|
||
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
|
||
isBootstrapping={iframeSkill.isBootstrapping}
|
||
/>
|
||
)}
|
||
|
||
{!disabled &&
|
||
!showWelcomeStyle &&
|
||
!followupsHidden &&
|
||
(followupsLoading || followups.length > 0) && (
|
||
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">
|
||
<div className="flex items-center gap-2">
|
||
{followupsLoading ? (
|
||
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
|
||
{t.inputBox.followupLoading}
|
||
</div>
|
||
) : (
|
||
<Suggestions className="min-h-16 w-fit items-start">
|
||
{followups.map((s) => (
|
||
<Suggestion
|
||
key={s}
|
||
suggestion={s}
|
||
onClick={() => handleFollowupClick(s)}
|
||
/>
|
||
))}
|
||
<Button
|
||
aria-label={t.common.close}
|
||
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
|
||
variant="outline"
|
||
size="sm"
|
||
type="button"
|
||
onClick={() => setFollowupsHidden(true)}
|
||
>
|
||
<XIcon className="size-4" />
|
||
</Button>
|
||
</Suggestions>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{t.inputBox.followupConfirmTitle}</DialogTitle>
|
||
<DialogDescription>
|
||
{t.inputBox.followupConfirmDescription}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||
{t.common.cancel}
|
||
</Button>
|
||
<Button variant="secondary" onClick={confirmAppendAndSend}>
|
||
{t.inputBox.followupConfirmAppend}
|
||
</Button>
|
||
<Button onClick={confirmReplaceAndSend}>
|
||
{t.inputBox.followupConfirmReplace}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// SuggestionList 容器
|
||
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({
|
||
bootstrapAndLockSkills,
|
||
isBootstrapping,
|
||
}: {
|
||
bootstrapAndLockSkills: (params: {
|
||
selectedSkills: SelectedSkillPayloadItem[];
|
||
title: string;
|
||
}) => Promise<boolean>;
|
||
isBootstrapping: boolean;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const { textInput } = usePromptInputController();
|
||
const suggestions = t.inputBox.suggestions;
|
||
const promptSuggestions = suggestions.filter(
|
||
(
|
||
suggestion,
|
||
): suggestion is Exclude<
|
||
(typeof suggestions)[number],
|
||
{ type: "separator" }
|
||
> => !("type" in suggestion),
|
||
);
|
||
|
||
const handleSuggestionClick = useCallback(
|
||
(suggestion: {
|
||
prompt: string;
|
||
skill_id?: string[];
|
||
children?: SelectedSkillPayloadItem[];
|
||
suggestion: string;
|
||
}) => {
|
||
if (isBootstrapping) return;
|
||
|
||
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
||
const childSkills = (suggestion.children ?? [])
|
||
.map((item) => ({
|
||
id: String(item.id).trim(),
|
||
name: item.name?.trim() ?? "",
|
||
}))
|
||
.filter(
|
||
(item): item is { id: string; name: string } =>
|
||
Boolean(item.id) && Boolean(item.name),
|
||
);
|
||
if (childSkills.length > 0) {
|
||
void bootstrapAndLockSkills({
|
||
selectedSkills: childSkills,
|
||
title: suggestion.suggestion,
|
||
});
|
||
return;
|
||
}
|
||
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
|
||
void bootstrapAndLockSkills({
|
||
selectedSkills: suggestion.skill_id.map((id) => ({
|
||
id,
|
||
name: suggestion.suggestion,
|
||
})),
|
||
title: suggestion.suggestion,
|
||
});
|
||
return;
|
||
}
|
||
// 原有逻辑
|
||
if (!suggestion.prompt) return;
|
||
textInput.setInput(suggestion.prompt);
|
||
setTimeout(() => {
|
||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||
"textarea[name='message']",
|
||
);
|
||
if (textarea) {
|
||
const selStart = suggestion.prompt.indexOf("[");
|
||
const selEnd = suggestion.prompt.indexOf("]");
|
||
if (selStart !== -1 && selEnd !== -1) {
|
||
textarea.setSelectionRange(selStart, selEnd + 1);
|
||
textarea.focus();
|
||
}
|
||
}
|
||
}, 500);
|
||
},
|
||
[bootstrapAndLockSkills, isBootstrapping, textInput],
|
||
);
|
||
return (
|
||
<Suggestions
|
||
className="w-fit items-start"
|
||
data-testid="welcome-suggestions"
|
||
>
|
||
{promptSuggestions.map((suggestion) => (
|
||
<Suggestion
|
||
key={suggestion.suggestion}
|
||
icon={suggestion.icon}
|
||
suggestion={suggestion.suggestion}
|
||
onClick={() => handleSuggestionClick(suggestion)}
|
||
/>
|
||
))}
|
||
</Suggestions>
|
||
);
|
||
}
|
||
|
||
function AddAttachmentsButton({ className }: { className?: string }) {
|
||
const { t } = useI18n();
|
||
const attachments = usePromptInputAttachments();
|
||
return (
|
||
<Tooltip content={t.inputBox.addAttachments}>
|
||
<WorkspaceToolButton
|
||
className={cn("text-ws-base-1 hover:text-ws-interactive-primary", 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-[color] duration-200"
|
||
>
|
||
<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="currentColor"
|
||
/>
|
||
<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="currentColor"
|
||
/>
|
||
</svg>
|
||
</WorkspaceToolButton>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
function HistoryButton({
|
||
className,
|
||
router,
|
||
threadId,
|
||
}: {
|
||
className?: string;
|
||
router: AppRouterInstance;
|
||
threadId: string;
|
||
}) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<Tooltip content={t.inputBox.history}>
|
||
<WorkspaceToolButton
|
||
className={cn("text-ws-base-1 hover:text-ws-interactive-primary", className)}
|
||
onClick={() =>
|
||
router.replace(`/workspace/chats/${threadId}?is_chatting=true`)
|
||
}
|
||
>
|
||
<svg
|
||
className="transition-[color] duration-200"
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<circle
|
||
className="stroke-current transition-[stroke] duration-200"
|
||
cx="9"
|
||
cy="9"
|
||
r="8.5"
|
||
/>
|
||
<path
|
||
className="stroke-current transition-[stroke] duration-200"
|
||
d="M9 6V10H12"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</WorkspaceToolButton>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
function ExitChattingButton({
|
||
className,
|
||
router,
|
||
threadId,
|
||
}: {
|
||
className?: string;
|
||
router: AppRouterInstance;
|
||
threadId: string;
|
||
}) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<Tooltip content={t.inputBox.welcome}>
|
||
<WorkspaceToolButton
|
||
className={cn(
|
||
"text-ws-base-1 hover:text-ws-interactive-primary",
|
||
className,
|
||
)}
|
||
onClick={() =>
|
||
router.replace(`/workspace/chats/${threadId}?is_chatting=false`)
|
||
}
|
||
>
|
||
<svg
|
||
className="transition-[color] duration-200"
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<circle
|
||
className="stroke-current transition-[stroke] duration-200"
|
||
cx="9"
|
||
cy="9"
|
||
r="8.5"
|
||
/>
|
||
<path
|
||
className="stroke-current transition-[stroke] duration-200"
|
||
d="M6 9H12"
|
||
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;
|
||
clearSkill: (skillId?: string) => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
|
||
return (
|
||
<div className="flex min-w-0 w-full items-center h-full gap-2">
|
||
<Tooltip content={t.inputBox.selectSkill}>
|
||
<div ref={skillButtonRef} className="shrink-0">
|
||
<WorkspaceToolButton
|
||
className={cn("shrink-0", className)}
|
||
onClick={openSkillDialog}
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="size-4 text-ws-base-1 transition-[color] duration-200 group-hover:text-ws-interactive-primary"
|
||
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="currentColor"
|
||
/>
|
||
</svg>
|
||
</WorkspaceToolButton>
|
||
</div>
|
||
</Tooltip>
|
||
{isBootstrapping ? (
|
||
<Tag className="bg-background text-muted-foreground gap-2 border">
|
||
<Loader2Icon className="size-3 animate-spin" />
|
||
{t.common.loading}
|
||
</Tag>
|
||
) : null}
|
||
{!isBootstrapping && selectedSkills.length > 0 ? (
|
||
<div
|
||
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;
|
||
}}
|
||
>
|
||
{selectedSkills.map((skill, index) => (
|
||
<Tag
|
||
key={`${skill.skill_id}-${skill.title}-${index}`}
|
||
className="shrink-0"
|
||
>
|
||
<span className="text-xs leading-3">{skill.title}</span>
|
||
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
||
<button
|
||
onClick={() => clearSkill(skill.skill_id)}
|
||
className="hover:bg-muted-foreground/20 ml-1 inline-flex size-4 items-center justify-center rounded-full align-middle"
|
||
type="button"
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 附件预览栏 - 在输入框上方显示
|
||
function AttachmentPreviewBar({
|
||
references,
|
||
threadId,
|
||
onRemoveReference,
|
||
}: {
|
||
references: PromptInputReference[];
|
||
threadId: string;
|
||
onRemoveReference: (reference: PromptInputReference) => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const referenceSourceLabels = {
|
||
artifact: t.inputBox.referenceSourceArtifact,
|
||
upload: t.inputBox.referenceSourceUpload,
|
||
} as const;
|
||
const attachments = usePromptInputAttachments();
|
||
const hasReferences = references.length > 0;
|
||
const hasAttachmentFiles = attachments.files.length > 0;
|
||
|
||
if (!hasAttachmentFiles && !hasReferences) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="absolute bottom-full left-0 z-20 mb-3 ml-1 flex max-w-full justify-start">
|
||
<div className="flex max-w-full flex-wrap items-center gap-2">
|
||
{hasAttachmentFiles && (
|
||
<PromptInputAttachments>
|
||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||
</PromptInputAttachments>
|
||
)}
|
||
{hasReferences && (
|
||
<div
|
||
className="inline-flex flex-row flex-wrap items-center gap-2 rounded-xl p-2"
|
||
data-testid="reference-inline-preview"
|
||
>
|
||
{references.map((reference) => {
|
||
const referenceUrl =
|
||
threadId && reference.path
|
||
? urlOfArtifact({
|
||
filepath: reference.path,
|
||
threadId,
|
||
})
|
||
: null;
|
||
const filename = reference.filename ?? "reference";
|
||
const imageMatch = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.exec(filename);
|
||
const extension = imageMatch?.[1]?.toLowerCase();
|
||
const mediaType = extension
|
||
? extension === "jpg"
|
||
? "image/jpeg"
|
||
: extension === "svg"
|
||
? "image/svg+xml"
|
||
: `image/${extension}`
|
||
: "application/octet-stream";
|
||
return (
|
||
<PromptInputAttachment
|
||
key={`${reference.ref_source}:${reference.path ?? reference.filename}`}
|
||
className="border"
|
||
data={{
|
||
type: "file",
|
||
id: `reference:${reference.ref_source}:${reference.path ?? reference.filename}`,
|
||
filename,
|
||
mediaType,
|
||
url: referenceUrl ?? "",
|
||
}}
|
||
data-testid="reference-chip"
|
||
onRemove={() => onRemoveReference(reference)}
|
||
title={`${referenceSourceLabels[reference.ref_source]}${reference.path ? ` · ${getPathTail(reference.path)}` : ""}`}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ExtraHeader 容器 - 有附件时上浮
|
||
function ExtraHeaderContainer({
|
||
hasAttachments,
|
||
children,
|
||
}: {
|
||
hasAttachments: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"absolute right-0 bottom-full left-0 z-30 flex items-center justify-center pb-4 transition-transform duration-300",
|
||
hasAttachments && "-translate-y-20",
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|