270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useRef, 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 { useNotification } from "@/core/notification/hooks";
|
|
import { useThreadSettings } from "@/core/settings";
|
|
import { bootstrapRemoteSkill } from "@/core/skills";
|
|
import { useThreadStream } from "@/core/threads/hooks";
|
|
import { textOfMessage } from "@/core/threads/utils";
|
|
import { uuid } from "@/core/utils/uuid";
|
|
import { env } from "@/env";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const UUID_REGEX =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
export default function ChatPage() {
|
|
const { t } = useI18n();
|
|
const [showFollowups, setShowFollowups] = useState(false);
|
|
const searchParams = useSearchParams(); const generatedThreadIdRef = useRef<string>("");
|
|
if (!generatedThreadIdRef.current) {
|
|
const queryThreadId = searchParams.get("thread_id")?.trim();
|
|
generatedThreadIdRef.current =
|
|
queryThreadId && UUID_REGEX.test(queryThreadId) ? queryThreadId : uuid();
|
|
}
|
|
|
|
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat({
|
|
newThreadId: generatedThreadIdRef.current,
|
|
});
|
|
const [settings, setSettings] = useThreadSettings(threadId);
|
|
const [mounted, setMounted] = useState(false);
|
|
useSpecificChatMode();
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const { showNotification } = useNotification();
|
|
const skillBootstrappedKeysRef = useRef<Set<string>>(new Set());
|
|
const skillBootstrappingKeysRef = useRef<Set<string>>(new Set());
|
|
|
|
const skillBootstrap = useMemo(() => {
|
|
const skillIdRaw = searchParams.get("skill_id")?.trim();
|
|
if (!skillIdRaw) return undefined;
|
|
|
|
const contentIds = skillIdRaw
|
|
.split(",")
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0)
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value));
|
|
|
|
// Deduplicate while preserving incoming order.
|
|
const uniqueContentIds = Array.from(new Set(contentIds));
|
|
if (uniqueContentIds.length === 0) return undefined;
|
|
|
|
const languageTypeRaw =
|
|
searchParams.get("languageType")?.trim() ??
|
|
searchParams.get("language_type")?.trim();
|
|
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
|
|
|
return {
|
|
contentIds: uniqueContentIds,
|
|
languageType: Number.isFinite(languageType) ? languageType : 0,
|
|
};
|
|
}, [searchParams]);
|
|
|
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
|
threadId: isNewThread ? undefined : threadId,
|
|
context: settings.context,
|
|
isMock,
|
|
onStart: () => {
|
|
setIsNewThread(false);
|
|
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
|
history.replaceState(null, "", `/workspace/chats/${threadId}`);
|
|
},
|
|
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 });
|
|
}
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!threadId || !skillBootstrap?.contentIds?.length) {
|
|
return;
|
|
}
|
|
|
|
const languageType = skillBootstrap.languageType ?? 0;
|
|
const initKey = `${threadId}:${skillBootstrap.contentIds.join(",")}:${languageType}`;
|
|
if (
|
|
skillBootstrappedKeysRef.current.has(initKey) ||
|
|
skillBootstrappingKeysRef.current.has(initKey)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
skillBootstrappingKeysRef.current.add(initKey);
|
|
|
|
const runBootstrap = async () => {
|
|
try {
|
|
await bootstrapRemoteSkill({
|
|
thread_id: threadId,
|
|
content_ids: skillBootstrap.contentIds,
|
|
language_type: languageType,
|
|
target_dir: "/mnt/user-data/uploads/skill",
|
|
clear_target: true,
|
|
});
|
|
|
|
skillBootstrappedKeysRef.current.add(initKey);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Skill initialization failed";
|
|
showNotification("Skill initialization failed", { body: message });
|
|
} finally {
|
|
skillBootstrappingKeysRef.current.delete(initKey);
|
|
}
|
|
};
|
|
|
|
void runBootstrap();
|
|
}, [threadId, skillBootstrap, showNotification]);
|
|
|
|
const handleSubmit = useCallback(
|
|
(message: PromptInputMessage) => {
|
|
void sendMessage(threadId, message);
|
|
},
|
|
[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;
|
|
|
|
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 px-4",
|
|
isNewThread
|
|
? "bg-background/0 backdrop-blur-none"
|
|
: "bg-background/80 shadow-xs backdrop-blur",
|
|
)}
|
|
>
|
|
<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 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", !isNewThread && "pt-10")}
|
|
threadId={threadId}
|
|
thread={thread}
|
|
paddingBottom={messageListPaddingBottom}
|
|
/>
|
|
</div>
|
|
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
|
<div
|
|
className={cn(
|
|
"relative w-full",
|
|
isNewThread && "-translate-y-[calc(50vh-96px)]",
|
|
isNewThread
|
|
? "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={isNewThread}
|
|
threadId={threadId}
|
|
autoFocus={isNewThread}
|
|
status={
|
|
thread.error
|
|
? "error"
|
|
: thread.isLoading
|
|
? "streaming"
|
|
: "ready"
|
|
}
|
|
context={settings.context}
|
|
extraHeader={
|
|
isNewThread && <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>
|
|
);
|
|
}
|