- 后端新增 PublishSquare/PublishSquaresSettings 配置与读写接口 - agents/skills 增加 square_id 字段及对应迁移脚本 - 前端新增发布广场设置页、技能转移选择器,画廊按广场分 Tab 展示 - lead_agent prompt 及若干持久化层适配调整 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { BotIcon, PlusSquare } from "lucide-react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useRouteBase } from "@/core/route-base";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { AgentInfoSidebar } from "@/components/workspace/agent-info-sidebar";
|
|
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";
|
|
import { InputBox } from "@/components/workspace/input-box";
|
|
import { MessageList } from "@/components/workspace/messages";
|
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
|
import { ThreadTitle } from "@/components/workspace/thread-title";
|
|
import { TodoList } from "@/components/workspace/todo-list";
|
|
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
|
import { Tooltip } from "@/components/workspace/tooltip";
|
|
import { useAgent } from "@/core/agents";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { useModels } from "@/core/models/hooks";
|
|
import { useNotification } from "@/core/notification/hooks";
|
|
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
|
import { useThreadStream } from "@/core/threads/hooks";
|
|
import { textOfMessage } from "@/core/threads/utils";
|
|
import { env } from "@/env";
|
|
import { desensitize } from "@/lib/desensitize";
|
|
|
|
export default function AgentChatPage() {
|
|
const routeBase = useRouteBase();
|
|
const { t } = useI18n();
|
|
const [showFollowups, setShowFollowups] = useState(false);
|
|
const router = useRouter();
|
|
|
|
const { agent_id } = useParams<{
|
|
agent_id: string;
|
|
}>();
|
|
|
|
// If ChatPage stashed an opening message for this agent, skip the landing
|
|
// screen on first render so the user doesn't see a flash of the welcome UI
|
|
// before the auto-sent message takes over.
|
|
const hasPendingForThisAgent = () => {
|
|
if (typeof window === "undefined" || !agent_id) return false;
|
|
try {
|
|
const raw = sessionStorage.getItem("pendingAgentMessage");
|
|
if (!raw) return false;
|
|
const parsed = JSON.parse(raw);
|
|
return parsed?.agentId === agent_id && !!parsed?.text;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const [hasStartedConversation, setHasStartedConversation] = useState(
|
|
hasPendingForThisAgent,
|
|
);
|
|
|
|
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
|
|
const agentDisplayName = desensitize(
|
|
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
|
|
);
|
|
|
|
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
|
useThreadChat();
|
|
const [settings, setSettings] = useThreadSettings(threadId);
|
|
const { tokenUsageEnabled } = useModels();
|
|
|
|
useEffect(() => {
|
|
if (isNewThread) {
|
|
setHasStartedConversation(hasPendingForThisAgent());
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isNewThread, threadId, agent_id]);
|
|
|
|
const { showNotification } = useNotification();
|
|
const [thread, sendMessage] = useThreadStream({
|
|
threadId: isNewThread ? undefined : threadId,
|
|
assistantId: agent_id,
|
|
context: { ...settings.context, agent_id },
|
|
onStart: (createdThreadId) => {
|
|
setThreadId(createdThreadId);
|
|
setIsNewThread(false);
|
|
// Keep the current component mounted while updating the hash route.
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`,
|
|
);
|
|
},
|
|
onFinish: (state) => {
|
|
if (document.hidden || !document.hasFocus()) {
|
|
let body = "Conversation finished";
|
|
const lastMessage = state.messages[state.messages.length - 1];
|
|
if (lastMessage) {
|
|
const textContent = textOfMessage(lastMessage);
|
|
if (textContent) {
|
|
body =
|
|
textContent.length > 200
|
|
? textContent.substring(0, 200) + "..."
|
|
: textContent;
|
|
}
|
|
}
|
|
showNotification(state.title, { body });
|
|
}
|
|
},
|
|
});
|
|
|
|
const handleSubmit = useCallback(
|
|
(message: PromptInputMessage) => {
|
|
setHasStartedConversation(true);
|
|
// Remember this agent so the new-chat landing screen pre-selects it next
|
|
// time (covers both direct agent chats and the ChatPage handoff replay).
|
|
saveLastAgentId(agent_id);
|
|
window.dispatchEvent(
|
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
|
);
|
|
void sendMessage(threadId, message, { agent_id });
|
|
},
|
|
[sendMessage, threadId, agent_id],
|
|
);
|
|
|
|
// ChatPage stashes the opening message in sessionStorage when the user picks
|
|
// an agent on the landing screen, then redirects here. Replay it once so the
|
|
// user doesn't have to re-type the question on the agent chat page.
|
|
const pendingMessageHandled = useRef(false);
|
|
useEffect(() => {
|
|
if (pendingMessageHandled.current) return;
|
|
if (!agent_id) return;
|
|
let pending: { agentId?: string; threadId?: string; text?: string } | null = null;
|
|
try {
|
|
const raw = sessionStorage.getItem("pendingAgentMessage");
|
|
if (raw) pending = JSON.parse(raw);
|
|
} catch {
|
|
pending = null;
|
|
}
|
|
if (!pending || pending.agentId !== agent_id || !pending.text) return;
|
|
if (pending.threadId) {
|
|
if (pending.threadId !== threadId) return;
|
|
} else if (!isNewThread) {
|
|
return;
|
|
}
|
|
pendingMessageHandled.current = true;
|
|
sessionStorage.removeItem("pendingAgentMessage");
|
|
handleSubmit({ text: pending.text, files: [] });
|
|
}, [agent_id, threadId, isNewThread, handleSubmit]);
|
|
|
|
const handleClarificationSubmit = useCallback(
|
|
(answer: string) => {
|
|
setHasStartedConversation(true);
|
|
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
|
},
|
|
[sendMessage, threadId, agent_id],
|
|
);
|
|
|
|
const handleStop = useCallback(async () => {
|
|
await thread.stop();
|
|
}, [thread]);
|
|
|
|
const isLandingState = isNewThread && !hasStartedConversation;
|
|
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
|
|
|
const status = thread.error
|
|
? "error"
|
|
: thread.isLoading
|
|
? "streaming"
|
|
: "ready";
|
|
|
|
return (
|
|
<ThreadContext.Provider value={{ thread }}>
|
|
<ChatBox threadId={threadId}>
|
|
{/* Root: fixed-height horizontal split. The left main column owns the
|
|
chat (home / conversation) and the input; the right sidebar is a
|
|
separate flex sibling, so nothing in the main column can ever bleed
|
|
over it. */}
|
|
<div className="flex size-full min-h-0 overflow-hidden">
|
|
{/* ── Left: main conversation column ───────────────────────────── */}
|
|
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
{/* Header — in normal flow (not absolute), fixed height. */}
|
|
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
|
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
|
<BotIcon className="text-primary h-3.5 w-3.5" />
|
|
<span
|
|
className="max-w-[200px] truncate text-xs font-medium"
|
|
title={agentDisplayName}
|
|
>
|
|
{agentDisplayName}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
|
<ThreadTitle threadId={threadId} thread={thread} />
|
|
</div>
|
|
|
|
<div className="flex items-center">
|
|
<Tooltip content={t.agents.newChat}>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
router.push(
|
|
`/page/workspace/agents/${agent_id}/chats/new`,
|
|
);
|
|
}}
|
|
>
|
|
<PlusSquare /> {t.agents.newChat}
|
|
</Button>
|
|
</Tooltip>
|
|
<TokenUsageIndicator
|
|
enabled={tokenUsageEnabled}
|
|
messages={thread.messages}
|
|
/>
|
|
<ExportTrigger threadId={threadId} />
|
|
<ArtifactTrigger />
|
|
</div>
|
|
</header>
|
|
|
|
{isLandingState ? (
|
|
/* ── home: centered hero + suggested questions + input ──────
|
|
Everything lives inside the scrollable main column. No fixed,
|
|
no absolute, no viewport centering — so the avatar can't touch
|
|
the header and the input can't cover the sidebar. */
|
|
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
|
<AgentWelcome
|
|
agent={agent}
|
|
agentName={agentDisplayName}
|
|
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
|
disabled={isDemoMode}
|
|
className="mb-7"
|
|
/>
|
|
<div className="w-full max-w-(--container-width-md)">
|
|
<InputBox
|
|
className="w-full"
|
|
isNewThread
|
|
hideAdminRecommendedQuestions
|
|
threadId={threadId}
|
|
assistantId={agent_id}
|
|
autoFocus
|
|
status={status}
|
|
context={settings.context}
|
|
disabled={isDemoMode}
|
|
onContextChange={(context) =>
|
|
setSettings("context", context)
|
|
}
|
|
onFollowupsVisibilityChange={setShowFollowups}
|
|
onSubmit={handleSubmit}
|
|
onStop={handleStop}
|
|
/>
|
|
{isDemoMode && (
|
|
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
|
{t.common.notAvailableInDemoMode}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* ── conversation (active / history): messages scroll, input
|
|
docked at the bottom of the main column only. */
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<MessageList
|
|
className="min-h-0 flex-1"
|
|
threadId={threadId}
|
|
thread={thread}
|
|
paddingBottom={showFollowups ? 96 : 24}
|
|
tokenUsageEnabled={tokenUsageEnabled}
|
|
onClarificationSubmit={handleClarificationSubmit}
|
|
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
|
/>
|
|
|
|
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
|
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
|
<TodoList
|
|
className="bg-background/5 mb-2"
|
|
todos={thread.values.todos ?? []}
|
|
hidden={
|
|
!thread.values.todos ||
|
|
thread.values.todos.length === 0
|
|
}
|
|
/>
|
|
<InputBox
|
|
className="w-full"
|
|
isNewThread={false}
|
|
threadId={threadId}
|
|
assistantId={agent_id}
|
|
status={status}
|
|
context={settings.context}
|
|
disabled={isDemoMode}
|
|
onContextChange={(context) =>
|
|
setSettings("context", context)
|
|
}
|
|
onFollowupsVisibilityChange={setShowFollowups}
|
|
onSubmit={handleSubmit}
|
|
onStop={handleStop}
|
|
/>
|
|
{isDemoMode && (
|
|
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
|
{t.common.notAvailableInDemoMode}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* ── Right: read-only agent profile sidebar ───────────────────── */}
|
|
<AgentInfoSidebar
|
|
agent={agent}
|
|
agentName={agentDisplayName}
|
|
disabled={thread.isLoading || isDemoMode}
|
|
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
|
/>
|
|
</div>
|
|
</ChatBox>
|
|
</ThreadContext.Provider>
|
|
);
|
|
}
|