Merge branch 'likun' into master

This commit is contained in:
吉鑫升 committed 2026-05-13 17:02:22 +08:00
commit faca2c9a2d
563 files changed
+95263 -94665

No files matched your search

+222 -213
View File
@@ -1,224 +1,233 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, 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";
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 { 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 { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
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}#/workspace/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 });
}
},
});
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, 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";
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 { 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 { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const router = useRouter();
const { agent_id } = useParams<{
agent_id: string;
}>();
const { agent } = useAgent(agent_id);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
}
}, [isNewThread, threadId]);
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}#/workspace/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);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { agent_id });
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread }}>
<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 gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<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="text-xs font-medium">
{agent?.name ?? agent_id}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_id}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<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}
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread }}>
<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 gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<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="text-xs font-medium">
{agent?.name ?? agent_id}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/workspace/agents/${agent_id}/chats/new`);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<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-sm)"
: "max-w-(--container-width-md)",
)}
>
<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>
<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 && (
<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}
/>
{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>
);
}
</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-sm)"
: "max-w-(--container-width-md)",
)}
>
<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>
<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 && (
<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}
/>
{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>
);
}
+5 -5
View File
@@ -1,5 +1,5 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
}
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
}
+209 -201
View File
@@ -1,211 +1,219 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } 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 { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
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();
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(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}#/workspace/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 });
}
},
});
"use client";
import { useCallback, useEffect, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } 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 { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
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();
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(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}#/workspace/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 handleSubmit = useCallback(
(message: PromptInputMessage) => {
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
},
[sendMessage, threadId],
);
const handleClarificationSubmit = useCallback(
async (answer: string) => {
setHasStartedConversation(true);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
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}
await thread.stop();
}, [thread]);
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-sm)"
: "max-w-(--container-width-md)",
)}
>
<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)
}
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>
);
}
</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-sm)"
: "max-w-(--container-width-md)",
)}
>
<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)
}
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>
);
}
+74 -74
View File
@@ -1,74 +1,74 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
export default function ChatsPage() {
const { t } = useI18n();
const { data: threads } = useThreads();
const [search, setSearch] = useState("");
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
const filteredThreads = useMemo(() => {
return threads?.filter((thread) => {
return (
thread.metadata?.thread_type !== "scheduler" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
);
});
}, [threads, search]);
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-center pt-8">
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{titleOfThread(thread)}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
))}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
export default function ChatsPage() {
const { t } = useI18n();
const { data: threads } = useThreads();
const [search, setSearch] = useState("");
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
const filteredThreads = useMemo(() => {
return threads?.filter((thread) => {
return (
thread.metadata?.thread_type !== "scheduler" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
);
});
}, [threads, search]);
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-center pt-8">
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{titleOfThread(thread)}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
))}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
+25 -25
View File
@@ -1,25 +1,25 @@
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { Hero } from "@/components/landing/hero";
import { CaseStudySection } from "@/components/landing/sections/case-study-section";
import { CommunitySection } from "@/components/landing/sections/community-section";
import { SandboxSection } from "@/components/landing/sections/sandbox-section";
import { SkillsSection } from "@/components/landing/sections/skills-section";
import { WhatsNewSection } from "@/components/landing/sections/whats-new-section";
export default function LandingPage() {
return (
<div className="min-h-screen w-full bg-[#0a0a0a]">
<Header />
<main className="flex w-full flex-col">
<Hero />
<CaseStudySection />
<SkillsSection />
<SandboxSection />
<WhatsNewSection />
<CommunitySection />
</main>
<Footer />
</div>
);
}
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { Hero } from "@/components/landing/hero";
import { CaseStudySection } from "@/components/landing/sections/case-study-section";
import { CommunitySection } from "@/components/landing/sections/community-section";
import { SandboxSection } from "@/components/landing/sections/sandbox-section";
import { SkillsSection } from "@/components/landing/sections/skills-section";
import { WhatsNewSection } from "@/components/landing/sections/whats-new-section";
export default function LandingPage() {
return (
<div className="min-h-screen w-full bg-[#0a0a0a]">
<Header />
<main className="flex w-full flex-col">
<Hero />
<CaseStudySection />
<SkillsSection />
<SandboxSection />
<WhatsNewSection />
<CommunitySection />
</main>
<Footer />
</div>
);
}
+67 -67
View File
@@ -1,67 +1,67 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
const DEFAULT_USERNAME = "guest";
type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
try {
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
setStatus("success");
router.replace("/workspace/chats/new");
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
</p>
)}
</div>
</div>
);
}
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
const DEFAULT_USERNAME = "guest";
type LoginStatus = "loading" | "success" | "error";
export default function LoginPage() {
const { username } = useParams<{ username?: string }>();
const router = useRouter();
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
let cancelled = false;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
try {
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
setStatus("success");
router.replace("/workspace/chats/new");
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [resolvedUsername, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
</p>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -1,20 +1,20 @@
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground max-w-md text-center text-sm">
The Vite rewrite currently focuses on the workspace chat flows first.
Marketing, docs, blog, and Next-specific server routes still need a
dedicated migration pass.
</p>
<Link
to="/workspace/chats/new"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Open workspace
</Link>
</main>
);
}
import { Link } from "react-router-dom";
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="text-muted-foreground max-w-md text-center text-sm">
The Vite rewrite currently focuses on the workspace chat flows first.
Marketing, docs, blog, and Next-specific server routes still need a
dedicated migration pass.
</p>
<Link
to="/workspace/chats/new"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Open workspace
</Link>
</main>
);
}
+31 -31
View File
@@ -1,31 +1,31 @@
"use client";
import { Navigate } from "react-router-dom";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
import { useSchedulerThread } from "@/core/scheduled-tasks";
export default function ScheduledChatPage() {
const { data, isLoading, error } = useSchedulerThread();
if (data?.thread_id) {
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
}
return (
<WorkspaceContainer>
<WorkspaceBody>
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{isLoading
? "正在打开定时任务助手..."
: error
? "无法打开定时任务助手"
: "准备定时任务助手..."}
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import { Navigate } from "react-router-dom";
import {
WorkspaceBody,
WorkspaceContainer,
} from "@/components/workspace/workspace-container";
import { useSchedulerThread } from "@/core/scheduled-tasks";
export default function ScheduledChatPage() {
const { data, isLoading, error } = useSchedulerThread();
if (data?.thread_id) {
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
}
return (
<WorkspaceContainer>
<WorkspaceBody>
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{isLoading
? "正在打开定时任务助手..."
: error
? "无法打开定时任务助手"
: "准备定时任务助手..."}
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
@@ -1,318 +1,318 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
function formatDuration(run?: ScheduledTaskRun | null) {
if (!run?.started_at || !run.finished_at) return "暂无";
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(duration) || duration < 0) return "暂无";
return `${Math.round(duration / 1000)} 秒`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
if (!file.previewable || !file.content) {
return (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
该文件不是文本内容,无法直接预览,请下载查看。
</div>
);
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
return JSON.stringify(
{
title: result.title,
format: result.format,
created_at: result.created_at,
files: result.files,
},
null,
2,
);
}, [result]);
useEffect(() => {
let cancelled = false;
async function loadFiles() {
if (!run || !result || resultFiles.length === 0) {
setFileContents([]);
return;
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
}
} finally {
if (!cancelled) {
setFilesLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [run, result, resultFiles]);
const handleDownloadResult = async () => {
if (!run) return;
try {
await downloadScheduledTaskRun(run.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDownloadFile = async (index: number) => {
if (!run) return;
try {
await downloadScheduledTaskRunFile(run.id, index);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/workspace/scheduled/tasks");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
</span>
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
</Button>
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
</div>
)}
{showMeta && !isLoading && !run && (
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
没有找到这条执行记录。
</div>
)}
{/* 内容区 */}
{!result && run && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{result.content || "无正文内容"}
</pre>
)}
</TabsContent>
<TabsContent value="files" className="m-0 p-3">
{filesLoading && (
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
</ScrollArea>
</Tabs>
)}
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
function formatDuration(run?: ScheduledTaskRun | null) {
if (!run?.started_at || !run.finished_at) return "暂无";
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(duration) || duration < 0) return "暂无";
return `${Math.round(duration / 1000)} 秒`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
if (!file.previewable || !file.content) {
return (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
该文件不是文本内容,无法直接预览,请下载查看。
</div>
);
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
return JSON.stringify(
{
title: result.title,
format: result.format,
created_at: result.created_at,
files: result.files,
},
null,
2,
);
}, [result]);
useEffect(() => {
let cancelled = false;
async function loadFiles() {
if (!run || !result || resultFiles.length === 0) {
setFileContents([]);
return;
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
}
} finally {
if (!cancelled) {
setFilesLoading(false);
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [run, result, resultFiles]);
const handleDownloadResult = async () => {
if (!run) return;
try {
await downloadScheduledTaskRun(run.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDownloadFile = async (index: number) => {
if (!run) return;
try {
await downloadScheduledTaskRunFile(run.id, index);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/workspace/scheduled/tasks");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
</span>
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
</Button>
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
</div>
)}
{showMeta && !isLoading && !run && (
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
没有找到这条执行记录。
</div>
)}
{/* 内容区 */}
{!result && run && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{result.content || "无正文内容"}
</pre>
)}
</TabsContent>
<TabsContent value="files" className="m-0 p-3">
{filesLoading && (
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => (
<div key={file.index} className="rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
</ScrollArea>
</Tabs>
)}
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}