This commit is contained in:
wghe committed 2026-05-21 18:35:56 +08:00
commit 6ebbf3aa32
21 files changed
+1317 -119

No files matched your search

@@ -6,6 +6,7 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
SparklesIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
@@ -38,11 +39,16 @@ import { Textarea } from "@/components/ui/textarea";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import {
useDeleteAgent,
useSetAgentFeatured,
useUpdateAgent,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { cn } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
@@ -53,6 +59,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -147,6 +154,26 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
// safe seed; the manage dialog can rewrite to 0..N-1 later).
const turningOn = agent.featured_order == null;
if (turningOn && !agent.published) {
toast.error("只有已发布的智能体可以加入首页选择器");
return;
}
try {
await setFeatured.mutateAsync({
id: agent.id,
order: turningOn ? Date.now() : null,
});
toast.success(turningOn ? "已加入首页选择器" : "已从首页选择器移除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
function toggleSkill(skillName: string) {
setSelectedSkills((prev) => {
if (prev.includes(skillName)) {
@@ -211,6 +238,16 @@ export function AgentCard({ agent }: AgentCardProps) {
已发布
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
title="已加入首页选择器"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</div>
</div>
{agent.description && (
@@ -263,6 +300,26 @@ export function AgentCard({ agent }: AgentCardProps) {
</Button>
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.featured_order != null &&
"text-amber-600 hover:text-amber-700",
)}
onClick={() => void handleToggleFeatured()}
disabled={setFeatured.isPending}
title={
agent.featured_order != null
? "从首页选择器移除"
: "加入首页选择器"
}
>
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
{isAdmin && (
<Button
size="icon"
@@ -469,6 +526,7 @@ export function AgentCard({ agent }: AgentCardProps) {
/>
)}
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
@@ -0,0 +1,240 @@
"use client";
import {
ArrowDownIcon,
ArrowUpIcon,
BotIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import {
useAgentSelector,
useAgents,
useReorderFeaturedAgents,
useSetAgentFeatured,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
import { cn } from "@/lib/utils";
interface AgentManageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Admin-only homepage agent picker management.
*
* Single list (no tabs, no per-user buckets): admin curates exactly what every
* user sees in the new-chat selector. Up/down to reorder, ✕ to remove, + to
* add a published agent. All actions hit the server immediately and react to
* TanStack Query invalidation, so the selector dropdown stays in lockstep.
*/
export function AgentManageDialog({
open,
onOpenChange,
}: AgentManageDialogProps) {
const { agents, isLoading } = useAgentSelector();
const { agents: allAgents } = useAgents();
const setFeaturedMutation = useSetAgentFeatured();
const reorderMutation = useReorderFeaturedAgents();
const [pickerOpen, setPickerOpen] = useState(false);
const candidates = useMemo(() => {
const existing = new Set(agents.map((a) => a.id));
return allAgents.filter((a) => a.published && !existing.has(a.id));
}, [agents, allAgents]);
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= agents.length) return;
const next = agents.map((a) => a.id);
[next[index], next[target]] = [next[target]!, next[index]!];
try {
await reorderMutation.mutateAsync(next);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const remove = async (agentId: string) => {
try {
await setFeaturedMutation.mutateAsync({ id: agentId, order: null });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const add = async (agent: Agent) => {
if (!agent.published) {
toast.error("只有已发布的智能体可以加入选择器");
return;
}
const maxOrder = agents.reduce(
(acc, a) => Math.max(acc, a.featured_order ?? 0),
-1,
);
try {
await setFeaturedMutation.mutateAsync({
id: agent.id,
order: maxOrder + 1,
});
setPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理 Agent 列表</DialogTitle>
<DialogDescription>
配置首页对话框里展示的 agent。所有人会看到同一份列表,按下方顺序展示。
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{isLoading ? (
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
加载中…
</div>
) : agents.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
还没有配置任何 agent。点击下方“添加 Agent”开始。
</div>
) : (
<div className="max-h-[50vh] divide-y overflow-y-auto rounded-md border">
{agents.map((agent, index) => (
<div
key={agent.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
{index + 1}
</span>
<BotIcon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[11px] text-muted-foreground">
{agent.description}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<Tooltip content="上移">
<Button
size="icon"
variant="ghost"
disabled={busy || index === 0}
onClick={() => void move(index, -1)}
className="size-7"
>
<ArrowUpIcon className="size-3.5" />
</Button>
</Tooltip>
<Tooltip content="下移">
<Button
size="icon"
variant="ghost"
disabled={busy || index === agents.length - 1}
onClick={() => void move(index, 1)}
className="size-7"
>
<ArrowDownIcon className="size-3.5" />
</Button>
</Tooltip>
<Tooltip content="从选择器移除">
<Button
size="icon"
variant="ghost"
disabled={busy}
onClick={() => void remove(agent.id)}
className="text-destructive hover:text-destructive size-7"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
</div>
))}
</div>
)}
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setPickerOpen(true)}
className="w-full"
>
<PlusIcon className="mr-1 size-3.5" />
添加 Agent
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">添加 Agent</DialogTitle>
<Command className={cn("**:data-[slot=command-input-wrapper]:h-auto")}>
<CommandInput placeholder="搜索可添加的 agent…" className="h-auto py-3.5" />
<CommandList>
<CommandEmpty>
{candidates.length === 0
? "没有更多已发布的 agent 可添加。"
: "没有找到匹配的 agent。"}
</CommandEmpty>
{candidates.length > 0 && (
<CommandGroup>
{candidates.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => void add(agent)}
>
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[10px] text-muted-foreground">
{agent.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,173 @@
"use client";
import { BotIcon, SettingsIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { useAgentSelector } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
interface AgentSelectorProps {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
disabled?: boolean;
}
/**
* Homepage agent picker — shows the admin-curated selector list.
*
* - Non-admins see a read-only dropdown.
* - Admins also see a "管理 Agent 列表" entry that opens the management dialog.
*
* Selecting an agent flips this chip to a coloured pill with a ✕ to clear.
* The actual handoff to ``AgentChatPage`` happens in ``ChatPage`` on submit.
*/
export function AgentSelector({
selectedAgentId,
onAgentChange,
disabled,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector();
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null;
}, [agents, selectedAgentId]);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onAgentChange(null);
};
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon className="size-3" />
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
</span>
)}
</PromptInputButton>
</DialogTrigger>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
<CommandInput placeholder="搜索 agent…" className="h-auto py-3.5" />
<CommandList>
<CommandEmpty>
{isLoading ? "加载中…" : "没有找到匹配的 agent"}
</CommandEmpty>
{agents.length > 0 && (
<CommandGroup>
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => handleSelect(agent)}
className={cn(
"flex items-center gap-2",
agent.id === selectedAgentId && "bg-accent",
)}
>
<BotIcon className="text-muted-foreground size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[10px] text-muted-foreground">
{agent.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{!isLoading && agents.length === 0 && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
{isAdmin
? "还没有配置任何 agent。点击下方 “管理” 添加几个。"
: "管理员还没有配置可选的 agent。"}
</div>
)}
</CommandList>
{isAdmin && (
<div className="border-t px-2 py-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="w-full justify-start text-xs"
onClick={() => {
setOpen(false);
setManageOpen(true);
}}
>
<SettingsIcon className="mr-2 size-3.5" />
管理 Agent 列表 …
</Button>
</div>
)}
</Command>
</DialogContent>
</Dialog>
{isAdmin && (
<AgentManageDialog open={manageOpen} onOpenChange={setManageOpen} />
)}
</>
);
}
@@ -64,6 +64,8 @@ import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
import {
ModelSelector,
ModelSelectorContent,
@@ -119,6 +121,7 @@ export function InputBox({
onSubmit,
onStop,
promptPrefix,
agentSelector,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -159,6 +162,15 @@ export function InputBox({
on: boolean;
onToggle: () => void;
};
/**
* Optional agent picker shown alongside the model selector. Only the new-chat
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
* thread is already bound to one agent.
*/
agentSelector?: {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
@@ -867,6 +879,13 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
disabled={disabled}
/>
)}
{promptPrefix && (
<Tooltip
content={
+50 -1
View File
@@ -1,7 +1,12 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
import type {
Agent,
AgentSelectorResponse,
CreateAgentRequest,
UpdateAgentRequest,
} from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -75,6 +80,50 @@ export async function deleteAgent(id: string): Promise<void> {
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
}
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
return res.json() as Promise<AgentSelectorResponse>;
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_ids: agentIds }),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to reorder featured agents: ${res.statusText}`,
);
}
}
export async function setAgentFeatured(
id: string,
order: number | null,
): Promise<Agent> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/${id}/featured`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order }),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to set featured: ${res.statusText}`,
);
}
return res.json() as Promise<Agent>;
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
+40
View File
@@ -9,7 +9,10 @@ import {
createAgent,
deleteAgent,
getAgent,
getAgentSelector,
listAgents,
reorderFeaturedAgents,
setAgentFeatured,
updateAgent,
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
@@ -70,3 +73,40 @@ export function useDeleteAgent() {
},
});
}
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
staleTime: 60_000,
});
return {
agents: data?.agents ?? [],
isLoading,
error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
export function useSetAgentFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order),
onSuccess: (_data, { id }) => {
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
});
}
export function useReorderFeaturedAgents() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (agentIds: string[]) => reorderFeaturedAgents(agentIds),
onSuccess: () => invalidateAgentQueries(queryClient),
});
}
+5
View File
@@ -14,6 +14,11 @@ export interface Agent {
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
}
export interface AgentSelectorResponse {
agents: Agent[];
}
export interface CreateAgentRequest {
+54 -29
View File
@@ -3,11 +3,10 @@
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
@@ -56,6 +55,11 @@ export default function AgentChatPage() {
}
}, [isNewThread, threadId]);
// Auto-submit a message that the user typed on ChatPage right before
// selecting this agent. ChatPage stashed it in sessionStorage so we don't
// depend on router state (the next/navigation shim doesn't carry it).
const autoSubmitDoneRef = useRef(false);
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
@@ -100,6 +104,29 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id],
);
useEffect(() => {
if (autoSubmitDoneRef.current) return;
if (!isNewThread || hasStartedConversation) return;
let raw: string | null = null;
try {
raw = sessionStorage.getItem("pendingAgentMessage");
} catch {
return;
}
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { agentId?: string; text?: string };
if (parsed.agentId !== agent_id || !parsed.text) {
return;
}
autoSubmitDoneRef.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: parsed.text, files: [] });
} catch {
sessionStorage.removeItem("pendingAgentMessage");
}
}, [agent_id, hasStartedConversation, handleSubmit, isNewThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
@@ -116,7 +143,12 @@ export default function AgentChatPage() {
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
// AgentChatPage skips the centered welcome/landing state entirely — when the
// user reaches this page they've already committed to an agent (either by
// selecting one on the homepage or by visiting the agent's URL), so we drop
// them straight into the conversation layout: messages area on top, input at
// the bottom. The agent badge in the header still identifies the agent.
const isLandingState = false;
return (
<ThreadContext.Provider value={{ thread }}>
@@ -196,32 +228,25 @@ export default function AgentChatPage() {
</div>
</div>
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={isLandingState}
threadId={threadId}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
+40
View File
@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -34,6 +35,7 @@ import { cn } from "@/lib/utils";
export default function ChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -44,6 +46,7 @@ export default function ChatPage() {
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
@@ -55,6 +58,7 @@ export default function ChatPage() {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedAgentId(null);
}
}, [isNewThread, threadId]);
@@ -105,6 +109,32 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// If the user picked an agent before sending the opening turn, hand off
// to AgentChatPage so the conversation runs against that agent. We stash
// the pending text in sessionStorage because the next/navigation shim
// doesn't carry router state across hash-route pushes. File attachments
// are bound to the current thread upload directory and would not match
// the new thread, so we skip the handoff when files are present and let
// the message run on Lead Agent instead.
if (
selectedAgentId &&
isNewThread &&
!hasStartedConversation &&
message.text &&
(!message.files || message.files.length === 0)
) {
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
);
} catch {
// sessionStorage may be unavailable in private mode; fall through.
}
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
return;
}
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
@@ -140,6 +170,8 @@ export default function ChatPage() {
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
selectedAgentId,
router,
settings.context.writing_mode,
writingArtifactPath,
],
@@ -258,6 +290,14 @@ export default function ChatPage() {
setSettings("context", context)
}
promptPrefix={promptPrefixProp}
agentSelector={
isLandingState
? {
selectedAgentId,
onAgentChange: setSelectedAgentId,
}
: undefined
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}