# Conflicts: # frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx # frontend-web/src/components/workspace/code-editor.tsx # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/pages/ChatPage.tsx # offline-backend-20260512/backend/app/gateway/routers/artifacts.py
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
|
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
|
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
|
|
import {
|
|
ChatBox,
|
|
useSpecificChatMode,
|
|
useThreadChat,
|
|
} from "@/components/workspace/chats";
|
|
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
|
import { InputBox } from "@/components/workspace/input-box";
|
|
import {
|
|
MessageList,
|
|
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
|
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
|
} 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 { Welcome } from "@/components/workspace/welcome";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { useModels } from "@/core/models/hooks";
|
|
import { useNotification } from "@/core/notification/hooks";
|
|
import { useThreadSettings } from "@/core/settings";
|
|
import { useThreadStream } from "@/core/threads/hooks";
|
|
import { useRouteBase } from "@/core/route-base";
|
|
import { textOfMessage } from "@/core/threads/utils";
|
|
import { env } from "@/env";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function ChatPage() {
|
|
const routeBase = useRouteBase();
|
|
const { t } = useI18n();
|
|
const [showFollowups, setShowFollowups] = useState(false);
|
|
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
|
useThreadChat();
|
|
const [settings, setSettings] = useThreadSettings(threadId);
|
|
const [mounted, setMounted] = useState(false);
|
|
const { tokenUsageEnabled } = useModels();
|
|
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
|
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
|
const { selectedArtifact } = useArtifacts();
|
|
useSpecificChatMode();
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isNewThread) {
|
|
setHasStartedConversation(false);
|
|
setPromptPrefixOn(false);
|
|
}
|
|
}, [isNewThread, threadId]);
|
|
|
|
const { showNotification } = useNotification();
|
|
|
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
|
threadId: isNewThread ? undefined : threadId,
|
|
context: settings.context,
|
|
isMock,
|
|
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}/chats/${createdThreadId}`,
|
|
);
|
|
},
|
|
onFinish: (state) => {
|
|
if (document.hidden || !document.hasFocus()) {
|
|
let body = "Conversation finished";
|
|
const lastMessage = state.messages.at(-1);
|
|
if (lastMessage) {
|
|
const textContent = textOfMessage(lastMessage);
|
|
if (textContent) {
|
|
body =
|
|
textContent.length > 200
|
|
? textContent.substring(0, 200) + "..."
|
|
: textContent;
|
|
}
|
|
}
|
|
showNotification(state.title, { body });
|
|
}
|
|
},
|
|
});
|
|
|
|
const writingArtifactPath = useMemo(() => {
|
|
const isMarkdown = (path: string | null | undefined) =>
|
|
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
|
if (isMarkdown(selectedArtifact)) {
|
|
return selectedArtifact ?? undefined;
|
|
}
|
|
return [...(thread.values.artifacts ?? [])]
|
|
.reverse()
|
|
.find((path) => isMarkdown(path));
|
|
}, [selectedArtifact, thread.values.artifacts]);
|
|
|
|
const handleSubmit = useCallback(
|
|
(message: PromptInputMessage) => {
|
|
// Only the opening turn of a brand-new thread may carry the prefix.
|
|
const shouldApplyPrefix =
|
|
isNewThread &&
|
|
!hasStartedConversation &&
|
|
promptPrefixOn &&
|
|
promptPrefixSettings?.enabled === true;
|
|
setHasStartedConversation(true);
|
|
window.dispatchEvent(
|
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
|
);
|
|
// The prefix is intentionally NOT concatenated into the message text:
|
|
// doing so would make the user see it. We only flag the request via
|
|
// context; the backend tags the message and PromptPrefixMiddleware
|
|
// injects the prefix into the LLM request only. Writing mode adds the
|
|
// active markdown artifact path so the agent keeps editing the canvas.
|
|
const extraContext: Record<string, unknown> = {};
|
|
if (shouldApplyPrefix) {
|
|
extraContext.prompt_prefix_enabled = true;
|
|
}
|
|
if (settings.context.writing_mode && writingArtifactPath) {
|
|
extraContext.writing_artifact_path = writingArtifactPath;
|
|
}
|
|
void sendMessage(
|
|
threadId,
|
|
message,
|
|
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
|
);
|
|
},
|
|
[
|
|
sendMessage,
|
|
threadId,
|
|
isNewThread,
|
|
hasStartedConversation,
|
|
promptPrefixOn,
|
|
promptPrefixSettings,
|
|
settings.context.writing_mode,
|
|
writingArtifactPath,
|
|
],
|
|
);
|
|
|
|
const handleClarificationSubmit = useCallback(
|
|
(answer: string) => {
|
|
setHasStartedConversation(true);
|
|
void sendMessage(threadId, { text: answer, files: [] });
|
|
},
|
|
[sendMessage, threadId],
|
|
);
|
|
const handleStop = useCallback(async () => {
|
|
await thread.stop();
|
|
}, [thread]);
|
|
|
|
const promptPrefixProp = useMemo(() => {
|
|
if (!promptPrefixSettings?.enabled) return undefined;
|
|
// The prefix toggle is offered only on the opening turn of a new thread.
|
|
if (!isNewThread || hasStartedConversation) return undefined;
|
|
return {
|
|
label: promptPrefixSettings.switch_label || "前缀",
|
|
on: promptPrefixOn,
|
|
onToggle: () => setPromptPrefixOn((v) => !v),
|
|
};
|
|
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
|
|
|
|
const messageListPaddingBottom = showFollowups
|
|
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
|
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
|
: undefined;
|
|
const isLandingState = isNewThread && !hasStartedConversation;
|
|
|
|
return (
|
|
<ThreadContext.Provider value={{ thread, isMock }}>
|
|
<ChatBox threadId={threadId}>
|
|
<div className="relative flex size-full min-h-0 justify-between">
|
|
<header
|
|
className={cn(
|
|
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
|
isLandingState
|
|
? "border-transparent bg-transparent"
|
|
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
|
)}
|
|
>
|
|
<div className="flex w-full items-center text-sm font-medium">
|
|
<ThreadTitle threadId={threadId} thread={thread} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<TokenUsageIndicator
|
|
enabled={tokenUsageEnabled}
|
|
messages={thread.messages}
|
|
/>
|
|
<ExportTrigger threadId={threadId} />
|
|
<ArtifactTrigger />
|
|
</div>
|
|
</header>
|
|
<main className="flex min-h-0 max-w-full grow flex-col">
|
|
<div className="flex size-full justify-center">
|
|
<MessageList
|
|
className={cn("size-full", !isLandingState && "pt-10")}
|
|
threadId={threadId}
|
|
thread={thread}
|
|
paddingBottom={messageListPaddingBottom}
|
|
tokenUsageEnabled={tokenUsageEnabled}
|
|
onClarificationSubmit={handleClarificationSubmit}
|
|
/>
|
|
</div>
|
|
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
|
<div
|
|
className={cn(
|
|
"relative w-full",
|
|
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
|
isLandingState
|
|
? "max-w-(--container-width-md)"
|
|
: "max-w-(--container-width-lg)",
|
|
)}
|
|
>
|
|
<div className="absolute -top-4 right-0 left-0 z-0">
|
|
<div className="absolute right-0 bottom-0 left-0">
|
|
<TodoList
|
|
className="bg-background/5"
|
|
todos={thread.values.todos ?? []}
|
|
hidden={
|
|
!thread.values.todos || thread.values.todos.length === 0
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{mounted ? (
|
|
<InputBox
|
|
className={cn("bg-background/5 w-full -translate-y-4")}
|
|
isNewThread={isLandingState}
|
|
threadId={threadId}
|
|
autoFocus={isLandingState}
|
|
status={
|
|
thread.error
|
|
? "error"
|
|
: thread.isLoading
|
|
? "streaming"
|
|
: "ready"
|
|
}
|
|
context={settings.context}
|
|
extraHeader={
|
|
isLandingState && <Welcome mode={settings.context.mode} />
|
|
}
|
|
disabled={
|
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
|
isUploading
|
|
}
|
|
onContextChange={(context) =>
|
|
setSettings("context", context)
|
|
}
|
|
promptPrefix={promptPrefixProp}
|
|
onFollowupsVisibilityChange={setShowFollowups}
|
|
onSubmit={handleSubmit}
|
|
onStop={handleStop}
|
|
/>
|
|
) : (
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
|
)}
|
|
/>
|
|
)}
|
|
{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}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</ChatBox>
|
|
</ThreadContext.Provider>
|
|
);
|
|
}
|