735 lines
24 KiB
TypeScript
735 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import type { ChatStatus } from "ai";
|
||
import {
|
||
CheckIcon,
|
||
GraduationCapIcon,
|
||
LightbulbIcon,
|
||
PaperclipIcon,
|
||
PlusIcon,
|
||
SparklesIcon,
|
||
RocketIcon,
|
||
XIcon,
|
||
ZapIcon,
|
||
} from "lucide-react";
|
||
import { useParams, useSearchParams } from "next/navigation";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ComponentProps,
|
||
} from "react";
|
||
|
||
import {
|
||
PromptInput,
|
||
PromptInputActionMenu,
|
||
PromptInputActionMenuContent,
|
||
PromptInputActionMenuItem,
|
||
PromptInputActionMenuTrigger,
|
||
PromptInputAttachment,
|
||
PromptInputAttachments,
|
||
PromptInputBody,
|
||
PromptInputButton,
|
||
PromptInputFooter,
|
||
PromptInputSubmit,
|
||
PromptInputTextarea,
|
||
PromptInputTools,
|
||
usePromptInputAttachments,
|
||
usePromptInputController,
|
||
type PromptInputMessage,
|
||
} from "@/components/ai-elements/prompt-input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
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 {
|
||
DropdownMenuGroup,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { useModels } from "@/core/models/hooks";
|
||
import type { AgentThreadContext } from "@/core/threads";
|
||
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 {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "../ui/dropdown-menu";
|
||
|
||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||
import { Tooltip } from "./tooltip";
|
||
|
||
const POST_MESSAGE_TYPES = {
|
||
SELECT_SKILL: "selectSkill",
|
||
OPEN_SKILL_DIALOG: "openSkillDialog",
|
||
} as const;
|
||
|
||
const RECEIVE_MESSAGE_TYPES = {
|
||
SELECTED_SKILL: "selectedSkill",
|
||
} as const;
|
||
|
||
type IframeSelectedSkillMessage = {
|
||
type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL;
|
||
id: string | number;
|
||
title: string;
|
||
};
|
||
|
||
type IframeSkillData = {
|
||
skill_id: string;
|
||
title: string;
|
||
};
|
||
|
||
function sendIframeMessageToParent(message: unknown): void {
|
||
if (window.parent !== window) {
|
||
window.parent.postMessage(message, "*");
|
||
}
|
||
}
|
||
|
||
function useEmbeddedIframeSkill() {
|
||
const searchParams = useSearchParams();
|
||
const skillIdFromQuery = searchParams.get("skill_id");
|
||
const titleFromQuery = searchParams.get("title");
|
||
const [selectedSkill, setSelectedSkill] = useState<IframeSkillData | null>(
|
||
null,
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (skillIdFromQuery && titleFromQuery) {
|
||
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
|
||
}
|
||
}, [skillIdFromQuery, titleFromQuery]);
|
||
|
||
useEffect(() => {
|
||
const handleMessage = (event: MessageEvent) => {
|
||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||
const { id, title } = event.data as IframeSelectedSkillMessage;
|
||
setSelectedSkill({ skill_id: String(id), title });
|
||
}
|
||
};
|
||
window.addEventListener("message", handleMessage);
|
||
return () => window.removeEventListener("message", handleMessage);
|
||
}, []);
|
||
|
||
const sendSelectSkill = useCallback((skill_id: string) => {
|
||
sendIframeMessageToParent({ type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id });
|
||
}, []);
|
||
|
||
const openSkillDialog = useCallback(() => {
|
||
sendIframeMessageToParent({
|
||
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
|
||
openSkillDialog: true,
|
||
});
|
||
}, []);
|
||
|
||
const clearSkill = useCallback(() => {
|
||
setSelectedSkill(null);
|
||
sendIframeMessageToParent({
|
||
type: POST_MESSAGE_TYPES.SELECT_SKILL,
|
||
skill_id: "0",
|
||
});
|
||
}, []);
|
||
|
||
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||
}
|
||
|
||
export function InputBox({
|
||
className,
|
||
disabled,
|
||
autoFocus,
|
||
status = "ready",
|
||
context,
|
||
extraHeader,
|
||
isNewThread,
|
||
hasSubmitted,
|
||
threadId: threadIdProp,
|
||
initialValue,
|
||
onContextChange,
|
||
onSubmit,
|
||
onStop,
|
||
...props
|
||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||
assistantId?: string | null;
|
||
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;
|
||
isNewThread?: boolean;
|
||
hasSubmitted?: boolean;
|
||
threadId?: string;
|
||
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 searchParams = useSearchParams();
|
||
const iframeSkill = useEmbeddedIframeSkill();
|
||
|
||
const params = useParams();
|
||
const threadId = threadIdProp ?? params?.thread_id;
|
||
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 [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);
|
||
|
||
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||
const effectiveIsFocused =
|
||
((isNewThread ?? false) && !hasSubmitted) || isFocused;
|
||
|
||
// 点击外部区域时收起输入框
|
||
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 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) {
|
||
return;
|
||
}
|
||
setIsFocused(false);
|
||
onSubmit?.(message);
|
||
},
|
||
[onSubmit, onStop, status],
|
||
);
|
||
|
||
const requestFormSubmit = useCallback(() => {
|
||
const form = promptRootRef.current?.querySelector("form");
|
||
form?.requestSubmit();
|
||
}, []);
|
||
|
||
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 (!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, isNewThread, threadId]);
|
||
|
||
return (
|
||
<div
|
||
ref={(el) => {
|
||
promptRootRef.current = el;
|
||
containerRef.current = el;
|
||
}}
|
||
className="relative w-full"
|
||
>
|
||
<AttachmentPreviewBar />
|
||
|
||
{extraHeader && (
|
||
<ExtraHeaderContainer hasAttachments={attachments.files.length > 0}>
|
||
{extraHeader}
|
||
</ExtraHeaderContainer>
|
||
)}
|
||
|
||
<PromptInput
|
||
className={cn(
|
||
"bg-background w-full rounded-2xl transition-all duration-300 ease-out",
|
||
"*:data-[slot='input-group']:rounded-[20px] *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:backdrop-blur-sm *:data-[slot='input-group']:shadow-none",
|
||
"*:data-[slot='input-group']:transition-[height] *:data-[slot='input-group']:duration-300 *:data-[slot='input-group']:ease-out",
|
||
!isNewThread &&
|
||
"*:data-[slot='input-group']:h-[200px] *:data-[slot='input-group']:shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
|
||
hasSubmitted &&
|
||
"*:data-[slot='input-group']:shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||
effectiveIsFocused
|
||
? "*:data-[slot='input-group']:h-[200px]"
|
||
: "*:data-[slot='input-group']:h-[80px]",
|
||
className,
|
||
)}
|
||
disabled={disabled}
|
||
globalDrop
|
||
multiple
|
||
onSubmit={handleSubmit}
|
||
{...props}
|
||
>
|
||
<PromptInputBody
|
||
className={cn("transition-[opacity,transform] duration-300 ease-out")}
|
||
>
|
||
<PromptInputTextarea
|
||
ref={textareaRef}
|
||
className={cn(
|
||
"size-full",
|
||
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
||
)}
|
||
disabled={disabled}
|
||
placeholder={t.inputBox.placeholder}
|
||
autoFocus={autoFocus}
|
||
defaultValue={initialValue}
|
||
onFocus={() => setIsFocused(true)}
|
||
/>
|
||
</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>
|
||
{/* TODO: Add more connectors here
|
||
<PromptInputActionMenu>
|
||
<PromptInputActionMenuTrigger className="px-2!" />
|
||
<PromptInputActionMenuContent>
|
||
<PromptInputActionAddAttachments
|
||
label={t.inputBox.addAttachments}
|
||
/>
|
||
</PromptInputActionMenuContent>
|
||
</PromptInputActionMenu> */}
|
||
<AddAttachmentsButton className="px-2!" />
|
||
<IframeSkillDialogButton
|
||
className="px-2!"
|
||
selectedSkill={iframeSkill.selectedSkill}
|
||
openSkillDialog={iframeSkill.openSkillDialog}
|
||
clearSkill={iframeSkill.clearSkill}
|
||
/>
|
||
{/* 参考 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]"></div>
|
||
</PromptInputTools>
|
||
</PromptInputFooter>
|
||
<PromptInputSubmit
|
||
className="absolute right-3 bottom-5 z-[20] border-0"
|
||
disabled={disabled}
|
||
variant="outline"
|
||
status={status}
|
||
/>
|
||
</PromptInput>
|
||
|
||
{isNewThread && !hasSubmitted && searchParams.get("mode") !== "skill" && (
|
||
<SuggestionListContainer
|
||
sendSelectSkill={iframeSkill.sendSelectSkill}
|
||
/>
|
||
)}
|
||
|
||
{!disabled &&
|
||
!isNewThread &&
|
||
!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">
|
||
加载中...
|
||
</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>提示</DialogTitle>
|
||
<DialogDescription>
|
||
请确认要如何处理当前的追加建议内容?
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button variant="secondary" onClick={confirmAppendAndSend}>
|
||
追加内容
|
||
</Button>
|
||
<Button onClick={confirmReplaceAndSend}>替换发送</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// SuggestionList 容器
|
||
function SuggestionListContainer({
|
||
sendSelectSkill,
|
||
}: {
|
||
sendSelectSkill: (skill_id: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
|
||
<SuggestionList sendSelectSkill={sendSelectSkill} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 快速选择skillbutton
|
||
function SuggestionList({
|
||
sendSelectSkill,
|
||
}: {
|
||
sendSelectSkill: (skill_id: string) => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const { textInput } = usePromptInputController();
|
||
|
||
const handleSuggestionClick = useCallback(
|
||
(suggestion: { prompt: string; skill_id?: string }) => {
|
||
// 如果有 skill_id,发送给宿主页
|
||
if (suggestion.skill_id) {
|
||
sendSelectSkill(suggestion.skill_id);
|
||
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);
|
||
},
|
||
[textInput, sendSelectSkill],
|
||
);
|
||
return (
|
||
<Suggestions className="min-h-16 w-fit items-start">
|
||
{t.inputBox.suggestions.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}>
|
||
<PromptInputButton
|
||
className={cn("group px-2! hover:bg-[#EAE2F5]", 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]"
|
||
>
|
||
<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>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
// 启动iframeSkillDialog
|
||
function IframeSkillDialogButton({
|
||
className,
|
||
selectedSkill,
|
||
openSkillDialog,
|
||
clearSkill,
|
||
}: {
|
||
className?: string;
|
||
selectedSkill: { skill_id: string; title: string } | null;
|
||
openSkillDialog: () => void;
|
||
clearSkill: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Tooltip content={t.inputBox.selectSkill}>
|
||
<PromptInputButton
|
||
className={cn("group 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"
|
||
>
|
||
<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>
|
||
</Tooltip>
|
||
{selectedSkill && (
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]"
|
||
>
|
||
{selectedSkill.title}
|
||
<button
|
||
onClick={clearSkill}
|
||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 附件预览栏 - 在输入框上方显示
|
||
function AttachmentPreviewBar() {
|
||
const attachments = usePromptInputAttachments();
|
||
|
||
if (!attachments.files.length) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="absolute bottom-full left-0 z-20 mb-3 ml-1 flex justify-start">
|
||
<PromptInputAttachments>
|
||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||
</PromptInputAttachments>
|
||
</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>
|
||
);
|
||
}
|