Merge branch 'master' into dhk-1
解决 8 处冲突: - app.py:同时挂载 public_embed + html_page_favorites 两个 router - .env:保留 dhk-1 的 CORS 配置(含 5174、ALLOW_ALL=0) - message-list.tsx:并入双方新增(onSetupWriting* 解构 + contentClassName/fitMessageRowsToContent 类型) - WorkspaceRoutes.tsx:采用 master 的路由结构(含 chats_iframe/IframeChatPage),补回 dhk-1 的 BusinessChainEditorPage 导入与 roundtable/chains 路由 - LoginPage.tsx:合并两套登录(master 的 token 换登录 + dhk-1 的 online-ticket),并集依赖数组 - ScheduledTasksPage.tsx:采用 master 的 HTML 定时任务表单(isHtmlTask 分支) - AIWritingForm.tsx:保留 dhk-1 的 useWritingForm 重构,补回 master 的笔记来源 useState;WritingFormContext.materialSource 扩展支持 'notebook' - useAIWritingStream.ts:保留 dhk-1 重构(仅 reducer/helpers,useAIWriting 已移至 AIWritingContext),不并入 master 在此文件的旧实现 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d49406d2f1
102 files changed
+6816
-6016
No files matched your search
@@ -26,7 +26,7 @@ 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 { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -114,6 +114,9 @@ export default function AgentChatPage() {
|
||||
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"),
|
||||
);
|
||||
@@ -216,6 +219,7 @@ export default function AgentChatPage() {
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,10 +23,15 @@ 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 { useAgentSelector } 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 {
|
||||
getLastAgentId,
|
||||
saveLastAgentId,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -47,6 +52,8 @@ export default function ChatPage() {
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -58,10 +65,22 @@ export default function ChatPage() {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedAgentId(null);
|
||||
// Pre-select the agent the user last started a conversation with so a
|
||||
// fresh chat defaults to it. Validated against the selector list below.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
// Drop a restored last-used agent that is no longer in the selector list
|
||||
// (deleted / unpublished / no longer curated), so we never hand off to a
|
||||
// dead agent. Wait for the list to load before deciding.
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) return;
|
||||
if (!selectorAgents.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
@@ -184,6 +203,14 @@ export default function ChatPage() {
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
// Persist the picked agent the moment it changes so the next new chat
|
||||
// defaults to it — even if the user never sends a message this session.
|
||||
// Passing null (the ✕ clear) wipes the stored preference.
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
@@ -248,6 +275,9 @@ export default function ChatPage() {
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) =>
|
||||
handleSubmit({ text, files: [] })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,7 +335,7 @@ export default function ChatPage() {
|
||||
isLandingState
|
||||
? {
|
||||
selectedAgentId,
|
||||
onAgentChange: setSelectedAgentId,
|
||||
onAgentChange: handleAgentChange,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
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 { TodoList } from "@/components/workspace/todo-list";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type BrainyChatSubmitMessage,
|
||||
parseBrainyChatSubmitMessage,
|
||||
} from "./iframe-chat-submit";
|
||||
|
||||
type BrainyChatStatus = "started" | "finished";
|
||||
|
||||
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
|
||||
window.parent?.postMessage(
|
||||
{
|
||||
source: "brainy-iframe",
|
||||
type: "brainy:chat-status",
|
||||
payload: {
|
||||
status,
|
||||
requestId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
|
||||
);
|
||||
|
||||
if (pendingRequestIdRef.current) {
|
||||
postBrainyChatStatus("started", pendingRequestIdRef.current);
|
||||
}
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (pendingRequestIdRef.current) {
|
||||
postBrainyChatStatus("finished", pendingRequestIdRef.current);
|
||||
pendingRequestIdRef.current = null;
|
||||
}
|
||||
|
||||
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,
|
||||
externalContext?: Record<string, unknown>,
|
||||
options?: { additionalKwargs?: Record<string, unknown> },
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
|
||||
const shouldApplyPrefix =
|
||||
isNewThread &&
|
||||
!hasStartedConversation &&
|
||||
promptPrefixOn &&
|
||||
promptPrefixSettings?.enabled === true;
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
|
||||
const extraContext: Record<string, unknown> = {};
|
||||
if (shouldApplyPrefix) {
|
||||
extraContext.prompt_prefix_enabled = true;
|
||||
}
|
||||
if (settings.context.writing_mode && writingArtifactPath) {
|
||||
extraContext.writing_artifact_path = writingArtifactPath;
|
||||
}
|
||||
if (externalContext) {
|
||||
Object.assign(extraContext, externalContext);
|
||||
}
|
||||
|
||||
void sendMessage(
|
||||
threadId,
|
||||
message,
|
||||
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
||||
options,
|
||||
);
|
||||
},
|
||||
[
|
||||
hasStartedConversation,
|
||||
isNewThread,
|
||||
promptPrefixOn,
|
||||
promptPrefixSettings,
|
||||
router,
|
||||
selectedAgentId,
|
||||
sendMessage,
|
||||
settings.context.writing_mode,
|
||||
threadId,
|
||||
writingArtifactPath,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<BrainyChatSubmitMessage>) => {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
|
||||
const { submitText, files, iframeContext, additionalKwargs } =
|
||||
parseBrainyChatSubmitMessage(data);
|
||||
|
||||
console.log("收到插件消息", {
|
||||
text: data.payload?.text,
|
||||
prompt: data.payload?.prompt,
|
||||
pageContext: data.payload?.pageContext,
|
||||
screenshot: data.payload?.screenshot,
|
||||
requestId: data.requestId,
|
||||
filesCount: files.length,
|
||||
});
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
pendingRequestIdRef.current = data.requestId ?? null;
|
||||
|
||||
handleSubmit(
|
||||
{ text: submitText, files },
|
||||
iframeContext,
|
||||
additionalKwargs ? { additionalKwargs } : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [handleSubmit]);
|
||||
|
||||
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} disableArtifacts>
|
||||
<div className="relative flex size-full min-h-0 justify-between overflow-hidden">
|
||||
{/*
|
||||
<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} />
|
||||
</div>
|
||||
</header>
|
||||
*/}
|
||||
<main className="flex min-h-0 max-w-full grow flex-col overflow-hidden">
|
||||
<div className="flex size-full min-w-0">
|
||||
{!isLandingState && (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
contentClassName="mx-0 max-w-full px-3 sm:px-4"
|
||||
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-full",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{isLandingState && mounted && (
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,34 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { loginByToken, loginByUsername } from '@/core/auth/api';
|
||||
import { setStoredAuth } from '@/core/auth';
|
||||
import {
|
||||
persistExternalLoginParams,
|
||||
readExternalLoginParams,
|
||||
resolveThemeFromQuery,
|
||||
} from "@/core/auth/external-login";
|
||||
import { resolveUsernameByOnlineTicket } from "@/core/auth/wujie-ticket-login";
|
||||
import { isWujieEnv, useWujieProps } from "@/core/embed/wujie";
|
||||
} from '@/core/auth/external-login';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
loginByYUserId,
|
||||
persistToolsToken,
|
||||
} from "@/core/auth/yuser-login";
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
} from "@/core/page-layout/sidebar-enabled";
|
||||
} from '@/core/page-layout/sidebar-enabled';
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
|
||||
const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new';
|
||||
|
||||
type LoginStatus = "loading" | "success" | "error";
|
||||
type LoginStatus = 'loading' | 'success' | 'error';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
@@ -36,21 +39,35 @@ export default function LoginPage() {
|
||||
() => isSidebarEnabledFromSearch(searchParams.toString()),
|
||||
[searchParams],
|
||||
);
|
||||
const redirectPath = useMemo(
|
||||
() =>
|
||||
searchParams.get('from') === IFRAME_LOGIN_SOURCE
|
||||
? IFRAME_REDIRECT_PATH
|
||||
: DEFAULT_REDIRECT_PATH,
|
||||
[searchParams],
|
||||
);
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||||
const yUserId = searchParams.get("yUserId")?.trim() ?? "";
|
||||
const toolToken = searchParams.get("token")?.trim() ?? "";
|
||||
const externalParams = useMemo(() => ({
|
||||
theme: searchParams.get("theme") ?? "",
|
||||
ticket: searchParams.get("ticket") ?? "",
|
||||
userId: searchParams.get("userId") ?? "",
|
||||
env: searchParams.get("env") ?? "",
|
||||
}), [searchParams]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
ticket: searchParams.get('ticket') ?? '',
|
||||
userId: searchParams.get('userId') ?? '',
|
||||
env: searchParams.get('env') ?? '',
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
const [status, setStatus] = useState<LoginStatus>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// 无界(wujie)父级通过 $wujie.props 注入的参数(ticket / userId / theme / env)。
|
||||
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
|
||||
@@ -69,7 +86,7 @@ export default function LoginPage() {
|
||||
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
|
||||
useEffect(() => {
|
||||
if (!isWujieEnv()) return;
|
||||
console.log("[wujie] sub-app props:", wujieProps);
|
||||
console.log('[wujie] sub-app props:', wujieProps);
|
||||
}, [wujieProps]);
|
||||
|
||||
// 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致
|
||||
@@ -93,34 +110,39 @@ export default function LoginPage() {
|
||||
let cancelled = false;
|
||||
|
||||
async function runLogin() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let deerflowUsername = resolvedUsername;
|
||||
let auth;
|
||||
if (authToken) {
|
||||
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||||
auth = await loginByToken(authToken);
|
||||
} else {
|
||||
let deerflowUsername = resolvedUsername;
|
||||
if (onlineTicket) {
|
||||
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
|
||||
} else if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
if (onlineTicket) {
|
||||
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
|
||||
} else if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||||
}
|
||||
|
||||
const auth = await loginByUsername(deerflowUsername);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus("success");
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : "/page/workspace/chats/new");
|
||||
setStatus('success');
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
error instanceof Error ? error.message : 'Login request failed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,19 +151,27 @@ export default function LoginPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, onlineTicket]);
|
||||
}, [
|
||||
resolvedUsername,
|
||||
router,
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
]);
|
||||
|
||||
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" && (
|
||||
{status === 'error' && (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
登录失败:{errorMessage || "未知错误"}
|
||||
登录失败:{errorMessage || '未知错误'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -545,13 +545,13 @@ export default function NewAgentPage() {
|
||||
没有匹配的技能。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="inline-flex max-w-full items-center"
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -560,7 +560,7 @@ export default function NewAgentPage() {
|
||||
onClick={() => toggleSkill(skill)}
|
||||
title={skill.description}
|
||||
className={cn(
|
||||
"h-8 max-w-[200px] rounded-r-none",
|
||||
"h-8 min-w-0 flex-1 rounded-r-none",
|
||||
!checked && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -68,9 +68,11 @@ type PublicRunFile = {
|
||||
type PublicRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
format: "markdown" | "text" | "json" | "mixed" | "html";
|
||||
files: PublicRunFile[];
|
||||
created_at: string;
|
||||
/** Present for ``html_page`` runs: the full generated HTML document. */
|
||||
html?: string | null;
|
||||
};
|
||||
|
||||
type PublicRun = {
|
||||
@@ -126,6 +128,12 @@ async function fetchPublicRunFile(runId: string, index: number): Promise<PublicF
|
||||
);
|
||||
}
|
||||
|
||||
type PublicRunHtml = { run_id: string; title?: string | null; html?: string | null };
|
||||
|
||||
async function fetchPublicRunHtml(runId: string): Promise<PublicRunHtml> {
|
||||
return fetchJson<PublicRunHtml>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/html`);
|
||||
}
|
||||
|
||||
async function updatePublicRunFile(
|
||||
runId: string,
|
||||
index: number,
|
||||
@@ -296,6 +304,13 @@ export default function PublicScheduledTaskPage() {
|
||||
if (run.status !== "succeeded") return false;
|
||||
const result = run.result;
|
||||
if (!result) return false;
|
||||
// HTML page runs always have something to render (the iframe preview).
|
||||
const isHtml =
|
||||
result.format === "html" ||
|
||||
(result.files ?? []).some(
|
||||
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
if (isHtml) return true;
|
||||
const hasBody = !!result.content && result.content.trim().length > 0;
|
||||
const hasMarkdownFile = (result.files ?? []).some(
|
||||
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
||||
@@ -550,6 +565,23 @@ type ContentTab = {
|
||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
||||
const isHtmlRun =
|
||||
run.result?.format === "html" ||
|
||||
(run.result?.files ?? []).some(
|
||||
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
// Prefer the inline copy; fall back to the LONGTEXT-backed /html endpoint for
|
||||
// large pages whose HTML was not inlined into result_json.
|
||||
const { data: htmlData } = useQuery({
|
||||
queryKey: ["public-scheduled-task", "html", run.id],
|
||||
queryFn: () => fetchPublicRunHtml(run.id),
|
||||
enabled: isHtmlRun && !run.result?.html,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
const htmlContent = run.result?.html ?? htmlData?.html ?? null;
|
||||
|
||||
// Skip files whose archive is missing on disk — otherwise the public viewer
|
||||
// would render a tab that 404s with "Result file is no longer available".
|
||||
const markdownFiles = useMemo(
|
||||
@@ -719,56 +751,74 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
下载原文件
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
{!isHtmlRun && (
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
{!isHtmlRun &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{tabs.length === 0 ? (
|
||||
{isHtmlRun ? (
|
||||
<div className="h-full p-4">
|
||||
{htmlContent ? (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={htmlContent}
|
||||
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tabs.length === 0 ? (
|
||||
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
|
||||
) : (
|
||||
<Tabs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Save, Code2, Trash2, X } from "lucide-react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,8 +26,11 @@ import {
|
||||
type ScheduledTaskRun,
|
||||
type ScheduledTaskRunMessage,
|
||||
updateScheduledTaskRunFile,
|
||||
useHtmlPageFavoriteActions,
|
||||
useHtmlPageFavorites,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
useScheduledTaskRunHtml,
|
||||
useScheduledTaskRunMessages,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
@@ -97,6 +100,22 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandboxed preview of a generated HTML document. The iframe has no
|
||||
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
|
||||
* of the server-side sanitization that already stripped script vectors.
|
||||
*/
|
||||
function HtmlPreviewFrame({ html }: { html: string }) {
|
||||
return (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={html}
|
||||
className="h-[70vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
|
||||
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
|
||||
@@ -217,6 +236,40 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
const canEditRun = run?.can_edit === true;
|
||||
const canDeleteRun = canEditRun;
|
||||
|
||||
// HTML page run: surface a sandboxed iframe preview + favorite button.
|
||||
const isHtmlRun = useMemo(() => {
|
||||
if (!result) return false;
|
||||
if (result.format === "html") return true;
|
||||
return (result.files ?? []).some(
|
||||
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
}, [result]);
|
||||
const { data: htmlData } = useScheduledTaskRunHtml(run_id, {
|
||||
enabled: isHtmlRun,
|
||||
live: runLive,
|
||||
});
|
||||
const htmlContent = htmlData?.html ?? result?.html ?? null;
|
||||
const htmlPageId = htmlData?.page_id ?? null;
|
||||
const favoriteActions = useHtmlPageFavoriteActions();
|
||||
const { data: htmlFavorites = [] } = useHtmlPageFavorites(isHtmlRun);
|
||||
const alreadyFavorited = !!htmlPageId && htmlFavorites.some((f) => f.page_id === htmlPageId);
|
||||
const handleFavorite = async () => {
|
||||
if (!htmlPageId) {
|
||||
toast.error("页面尚未生成或无法收藏");
|
||||
return;
|
||||
}
|
||||
if (alreadyFavorited) {
|
||||
toast.error("该页面已收藏,请勿重复收藏");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await favoriteActions.create.mutateAsync({ page_id: htmlPageId });
|
||||
toast.success("已收藏,可在新建 HTML 任务时作为参考风格");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "收藏失败");
|
||||
}
|
||||
};
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
@@ -444,11 +497,19 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
)}
|
||||
{run && (result || hasExecution) && (
|
||||
<Tabs
|
||||
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
|
||||
defaultValue={
|
||||
result ? (isHtmlRun ? "html" : fileCount > 0 ? "files" : "body") : "execution"
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
{result && isHtmlRun && (
|
||||
<TabsTrigger value="html" className="text-xs">
|
||||
<Code2 className="mr-1 size-3" />
|
||||
HTML 预览
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
@@ -476,6 +537,33 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{result && (
|
||||
<>
|
||||
{isHtmlRun && (
|
||||
<TabsContent value="html" className="m-0 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleFavorite}
|
||||
disabled={!htmlPageId || alreadyFavorited || favoriteActions.create.isPending}
|
||||
title={
|
||||
alreadyFavorited
|
||||
? "该页面已收藏"
|
||||
: "收藏此页面,作为后续 HTML 任务的参考风格"
|
||||
}
|
||||
>
|
||||
<Heart className={cn("size-4", alreadyFavorited && "fill-current text-rose-500")} />
|
||||
{alreadyFavorited ? "已收藏" : "收藏页面"}
|
||||
</Button>
|
||||
</div>
|
||||
{htmlContent ? (
|
||||
<HtmlPreviewFrame html={htmlContent} />
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
|
||||
@@ -45,10 +45,14 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
type PublishedScheduledTask,
|
||||
type ScheduledTask,
|
||||
type ScheduledTaskExecutionContext,
|
||||
useHtmlPageFavoriteActions,
|
||||
useHtmlPageFavorites,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskDeliveryProfile,
|
||||
useScheduledTaskRuns,
|
||||
@@ -110,6 +114,15 @@ const emptyForm = {
|
||||
prompt: "",
|
||||
execution_agent_name: "",
|
||||
require_markdown: false,
|
||||
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior.
|
||||
// Data gathering is delegated to the agent's own search skills — no explicit
|
||||
// search query / data source. ``html_model_name`` empty means "use the first
|
||||
// available model" — never a hardcoded GLM-5.
|
||||
task_kind: "agent" as "agent" | "html_page",
|
||||
html_page_prompt: "",
|
||||
html_negative_prompt: "",
|
||||
html_reference_favorite_id: "",
|
||||
html_model_name: "",
|
||||
};
|
||||
|
||||
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
|
||||
@@ -147,6 +160,25 @@ export default function ScheduledTasksPage() {
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const dialogOpen = creating || !!editing;
|
||||
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
|
||||
const htmlFavoriteActions = useHtmlPageFavoriteActions();
|
||||
const { models: availableModels } = useModels({ enabled: dialogOpen });
|
||||
|
||||
const handleDeleteFavorite = async (favoriteId: string) => {
|
||||
try {
|
||||
await htmlFavoriteActions.remove.mutateAsync(favoriteId);
|
||||
// Clear the selection if the removed favorite was the chosen reference.
|
||||
setForm((value) =>
|
||||
value.html_reference_favorite_id === favoriteId
|
||||
? { ...value, html_reference_favorite_id: "" }
|
||||
: value,
|
||||
);
|
||||
toast.success("收藏已删除", darkToastOpts.success);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败", darkToastOpts.error);
|
||||
}
|
||||
};
|
||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
|
||||
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
||||
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
||||
@@ -204,6 +236,7 @@ export default function ScheduledTasksPage() {
|
||||
const openEdit = (task: ScheduledTask) => {
|
||||
setCreating(false);
|
||||
setEditing(task);
|
||||
const html = task.execution_context?.html_config ?? {};
|
||||
setForm({
|
||||
name: task.name,
|
||||
description: task.description ?? "",
|
||||
@@ -212,6 +245,11 @@ export default function ScheduledTasksPage() {
|
||||
prompt: task.prompt,
|
||||
execution_agent_name: task.execution_agent_name ?? "",
|
||||
require_markdown: !!task.execution_context?.require_markdown,
|
||||
task_kind: task.execution_context?.task_kind === "html_page" ? "html_page" : "agent",
|
||||
html_page_prompt: html.page_prompt ?? "",
|
||||
html_negative_prompt: html.negative_prompt ?? "",
|
||||
html_reference_favorite_id: html.reference_favorite_id ?? "",
|
||||
html_model_name: html.model_name ?? "",
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -230,18 +268,48 @@ export default function ScheduledTasksPage() {
|
||||
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
|
||||
};
|
||||
|
||||
const isHtmlTask = form.task_kind === "html_page";
|
||||
// The model actually shown/used: the user's choice, else the first configured
|
||||
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
|
||||
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
|
||||
|
||||
// Build the execution_context for the current form. HTML tasks persist their
|
||||
// config under ``html_config``; regular tasks send an explicit ``agent`` kind
|
||||
// so switching a task back from HTML clears the old config.
|
||||
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
|
||||
if (!isHtmlTask) return { task_kind: "agent", html_config: undefined };
|
||||
return {
|
||||
task_kind: "html_page",
|
||||
html_config: {
|
||||
page_prompt: form.html_page_prompt.trim() || null,
|
||||
negative_prompt: form.html_negative_prompt.trim() || null,
|
||||
reference_favorite_id: form.html_reference_favorite_id || null,
|
||||
// Send the user's selection; fall back to the first configured model,
|
||||
// else null (backend then uses the system default). No hardcoded GLM-5.
|
||||
model_name: form.html_model_name.trim() || availableModels[0]?.name || null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const saveTask = async () => {
|
||||
try {
|
||||
const cronSchedule = buildCron(scheduleConfig);
|
||||
const executionContext = buildExecutionContext();
|
||||
// HTML tasks may leave the agent prompt empty — fall back to the page
|
||||
// prompt so the (non-null) prompt column always has something useful.
|
||||
const effectivePrompt = isHtmlTask
|
||||
? form.prompt.trim() || form.html_page_prompt.trim() || "生成 HTML 页面"
|
||||
: form.prompt;
|
||||
if (creating) {
|
||||
const task = await actions.create.mutateAsync({
|
||||
name: form.name || "定时任务",
|
||||
description: form.description.trim() || null,
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone || "Asia/Shanghai",
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
prompt: effectivePrompt,
|
||||
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
|
||||
require_markdown: isHtmlTask ? false : form.require_markdown,
|
||||
execution_context: executionContext,
|
||||
});
|
||||
setSelectedTaskId(task.task_id);
|
||||
toast.success("任务已创建", darkToastOpts.success);
|
||||
@@ -253,9 +321,10 @@ export default function ScheduledTasksPage() {
|
||||
description: form.description.trim() || null,
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone,
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
prompt: effectivePrompt,
|
||||
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
|
||||
require_markdown: isHtmlTask ? false : form.require_markdown,
|
||||
execution_context: executionContext,
|
||||
},
|
||||
});
|
||||
toast.success("任务已更新", darkToastOpts.success);
|
||||
@@ -411,6 +480,14 @@ export default function ScheduledTasksPage() {
|
||||
<Globe className="size-3" />已发布
|
||||
</Badge>
|
||||
)}
|
||||
{task.execution_context?.task_kind === "html_page" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
|
||||
>
|
||||
HTML 页面
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||
{desensitize(task.schedule_text ?? "")} · {task.timezone}
|
||||
@@ -835,8 +912,7 @@ export default function ScheduledTasksPage() {
|
||||
<DialogHeader className="min-w-0 shrink-0">
|
||||
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex min-h-0 max-h-[70vh] min-w-0 flex-col gap-3 overflow-y-auto pr-1">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
@@ -856,6 +932,20 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="任务描述(可选,将显示在公开页标题下方)"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">任务类型</p>
|
||||
<Tabs
|
||||
value={form.task_kind}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({ ...value, task_kind: v as "agent" | "html_page" }))
|
||||
}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="agent" className="flex-1">普通任务</TabsTrigger>
|
||||
<TabsTrigger value="html_page" className="flex-1">HTML 页面</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">执行频率</p>
|
||||
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
|
||||
@@ -867,60 +957,168 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="时区,例如:Asia/Shanghai"
|
||||
/>
|
||||
<Select
|
||||
value={form.execution_agent_name || "__default__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
execution_agent_name: v === "__default__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
rows={PROMPT_TEXTAREA_ROWS}
|
||||
className={cn(
|
||||
SCHEDULED_TASK_TEXTAREA_CLASS,
|
||||
"min-h-40 max-h-48",
|
||||
)}
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
|
||||
{!isHtmlTask && (
|
||||
<>
|
||||
<Select
|
||||
value={form.execution_agent_name || "__default__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
execution_agent_name: v === "__default__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isHtmlTask && (
|
||||
<>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
value={form.html_page_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, html_page_prompt: event.target.value }))
|
||||
}
|
||||
placeholder="页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.html_negative_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
|
||||
}
|
||||
placeholder="限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
|
||||
<div className="flex flex-col gap-1 rounded-md border p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((value) => ({ ...value, html_reference_favorite_id: "" }))
|
||||
}
|
||||
className={cn(
|
||||
"rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
|
||||
!form.html_reference_favorite_id && "bg-accent",
|
||||
)}
|
||||
>
|
||||
不参考(默认)
|
||||
</button>
|
||||
{htmlFavorites.map((fav) => {
|
||||
const selected = form.html_reference_favorite_id === fav.id;
|
||||
return (
|
||||
<div key={fav.id} className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((value) => ({ ...value, html_reference_favorite_id: fav.id }))
|
||||
}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
|
||||
selected && "bg-accent",
|
||||
)}
|
||||
title={fav.title}
|
||||
>
|
||||
{fav.title}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDeleteFavorite(fav.id)}
|
||||
disabled={htmlFavoriteActions.remove.isPending}
|
||||
title="删除该收藏"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{htmlFavorites.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
暂无收藏页面。可在执行记录的 HTML 预览中收藏页面后在此引用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">生成模型</p>
|
||||
<Select
|
||||
value={effectiveHtmlModel}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_model_name: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择生成模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model.id || model.name} value={model.name}>
|
||||
<span className="block truncate">{model.display_name || model.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="补充说明(可选):进一步约束智能体如何检索数据、页面包含哪些内容"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowLeft,
|
||||
CloudUpload,
|
||||
BookOpen,
|
||||
Files,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
|
||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||
import type { SourceItem } from "@/core/studio/types";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -36,6 +35,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ChatBox } from "@/components/workspace/chats/chat-box";
|
||||
import { rehypeCitations } from "@/core/rehype";
|
||||
|
||||
// ── 来源卡片图标 ────────────────────────────────────────
|
||||
@@ -101,24 +102,74 @@ function SourceCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
||||
|
||||
// ── 来源 Dialog ─────────────────────────────────────────
|
||||
function SourcesDialog({
|
||||
open,
|
||||
onClose,
|
||||
notebookName,
|
||||
notebookId,
|
||||
token,
|
||||
sources,
|
||||
isLoadingSources,
|
||||
selectedSourceIds,
|
||||
onToggle,
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
notebookName: string;
|
||||
notebookId: number;
|
||||
token: string;
|
||||
sources: SourceItem[];
|
||||
isLoadingSources: boolean;
|
||||
selectedSourceIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [panel, setPanel] = useState<"sources" | "text">("sources");
|
||||
const [textContent, setTextContent] = useState("");
|
||||
const [textTitle, setTextTitle] = useState("");
|
||||
const [submittingText, setSubmittingText] = useState(false);
|
||||
const [textError, setTextError] = useState("");
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
try {
|
||||
await uploadSourceFiles(notebookId, files, token);
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextSubmit = async () => {
|
||||
if (!textContent.trim()) return;
|
||||
setSubmittingText(true);
|
||||
setTextError("");
|
||||
try {
|
||||
await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token);
|
||||
setTextContent("");
|
||||
setTextTitle("");
|
||||
setPanel("sources");
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
setTextError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setSubmittingText(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent
|
||||
@@ -141,40 +192,89 @@ function SourcesDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* 左侧导航 */}
|
||||
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
||||
{[
|
||||
{ icon: Plus, label: "上传文件" },
|
||||
{ icon: CloudUpload, label: "从云端硬盘添加" },
|
||||
{ icon: Globe, label: "添加网站" },
|
||||
{ icon: AlignLeft, label: "复制的文字" },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="text-left leading-tight">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={UPLOAD_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => { void handleFileChange(e); }}
|
||||
/>
|
||||
<button
|
||||
disabled={uploading}
|
||||
onClick={() => { setPanel("sources"); fileInputRef.current?.click(); }}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
|
||||
<span className="text-left leading-tight">上传文件</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPanel("text"); setTextError(""); }}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-muted hover:text-foreground ${panel === "text" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<AlignLeft className="size-4 shrink-0" />
|
||||
<span className="text-left leading-tight">复制的文字</span>
|
||||
</button>
|
||||
{uploadError && (
|
||||
<p className="px-3 text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
{panel === "sources" ? (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||
<input
|
||||
autoFocus
|
||||
value={textTitle}
|
||||
onChange={(e) => setTextTitle(e.target.value)}
|
||||
placeholder="标题(可选)"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<textarea
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="在此粘贴或输入文字内容…"
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{textError && <p className="text-xs text-destructive">{textError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPanel("sources")}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
disabled={!textContent.trim() || submittingText}
|
||||
onClick={() => void handleTextSubmit()}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submittingText && <Loader2 className="size-3.5 animate-spin" />}
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -184,16 +284,19 @@ function SourcesDialog({
|
||||
// ── 主页面 ──────────────────────────────────────────────
|
||||
export default function StudioNotebookDetailPage() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
const { notebooks, isLoading } = useStudioNotebooks();
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
const queryClient = useQueryClient();
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
|
||||
const [initializedForNotebook, setInitializedForNotebook] = useState<number | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||
const THREADS_LIMIT = 5;
|
||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||
const [citationLoading, setCitationLoading] = useState(false);
|
||||
|
||||
@@ -206,6 +309,13 @@ export default function StudioNotebookDetailPage() {
|
||||
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
|
||||
|
||||
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && initializedForNotebook !== notebook?.id) {
|
||||
setInitializedForNotebook(notebook?.id ?? null);
|
||||
setSelectedSourceIds(sources.map((s) => s.id));
|
||||
}
|
||||
}, [sources, notebook?.id, initializedForNotebook]);
|
||||
const citationRehypePlugins = useMemo(() => {
|
||||
const mapped = sources.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -268,6 +378,8 @@ export default function StudioNotebookDetailPage() {
|
||||
const isChatView = activeThreadId !== null || hasMessages;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* 背景渐变(列表视图时显示) */}
|
||||
{!isChatView && (
|
||||
@@ -275,7 +387,7 @@ export default function StudioNotebookDetailPage() {
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
|
||||
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -299,75 +411,156 @@ export default function StudioNotebookDetailPage() {
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 列表视图:笔记本标题 + 历史线程列表 */}
|
||||
{!isChatView && (
|
||||
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-8 text-muted-foreground" />
|
||||
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{/* 右上角跳转按钮 */}
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
|
||||
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
|
||||
<div className="flex size-14 items-center justify-center leading-none select-none">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-10 text-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 输入框(列表视图内联) */}
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{(!notebookThreads || notebookThreads.length === 0) && (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
笔记本对话会显示在这里
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
|
||||
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notebookThreads.length > THREADS_LIMIT && (
|
||||
<button
|
||||
onClick={() => setThreadsExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
|
||||
{notebookThreads.map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted px-4 py-3 text-left transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,11 +592,11 @@ export default function StudioNotebookDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框区域 */}
|
||||
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
|
||||
{/* 聊天视图的底部输入框 */}
|
||||
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
|
||||
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -412,9 +605,17 @@ export default function StudioNotebookDetailPage() {
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -426,24 +627,6 @@ export default function StudioNotebookDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -484,11 +667,18 @@ export default function StudioNotebookDetailPage() {
|
||||
open={sourcesOpen}
|
||||
onClose={() => setSourcesOpen(false)}
|
||||
notebookName={notebook.name}
|
||||
notebookId={notebook.id}
|
||||
token={queryClient.getQueryData<string>(["studio", "token", getStoredAuth()?.email ?? ""]) ?? ""}
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
onUploadSuccess={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Files, Loader2, Plus } from "lucide-react";
|
||||
import { Files, Loader2, Plus, MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { createSearchSpace } from "@/core/studio/api";
|
||||
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import type { StudioNotebook } from "@/core/studio/types";
|
||||
import {
|
||||
@@ -15,81 +15,175 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function NotebookCardSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
|
||||
<div className="size-10 animate-pulse rounded-xl bg-muted" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
|
||||
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
|
||||
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-8">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookCard({ nb }: { nb: StudioNotebook }) {
|
||||
function NotebookCard({
|
||||
nb,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
|
||||
{nb.avatar_url ? (
|
||||
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
|
||||
) : (
|
||||
<Files className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="flex flex-1 flex-col p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
|
||||
{nb.avatar_url ? (
|
||||
<img src={nb.avatar_url} alt="" className="size-8 rounded object-contain" />
|
||||
) : (
|
||||
<Files className="size-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{/* spacer so title stays below icon */}
|
||||
<div className="size-7" />
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-1 pt-8">
|
||||
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* three-dot menu — absolute so it doesn't shift layout */}
|
||||
<div className="absolute right-4 top-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-full text-[#888] opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/10"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => onRename(nb)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(nb)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudioNotebooksPage() {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
// ── 新建 ──
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
// ── 重命名 ──
|
||||
const [renameTarget, setRenameTarget] = useState<StudioNotebook | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameError, setRenameError] = useState("");
|
||||
|
||||
// ── 删除 ──
|
||||
const [deleteTarget, setDeleteTarget] = useState<StudioNotebook | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
|
||||
function getToken() {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
return token;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) return;
|
||||
setCreating(true);
|
||||
setError("");
|
||||
setCreateError("");
|
||||
try {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
await createSearchSpace(trimmed, token);
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
// 刷新笔记本列表
|
||||
await createSearchSpace(trimmed, getToken());
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "创建失败");
|
||||
setCreateError(e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!renameTarget) return;
|
||||
const trimmed = renameName.trim();
|
||||
if (!trimmed) return;
|
||||
setRenaming(true);
|
||||
setRenameError("");
|
||||
try {
|
||||
await renameSearchSpace(renameTarget.id, trimmed, getToken());
|
||||
setRenameTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setRenameError(e instanceof Error ? e.message : "重命名失败");
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteSearchSpace(deleteTarget.id, getToken());
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-background p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||
<button
|
||||
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建笔记本
|
||||
@@ -100,13 +194,22 @@ export default function StudioNotebooksPage() {
|
||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
|
||||
{/* ── 新建 Dialog ── */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
@@ -114,25 +217,78 @@ export default function StudioNotebooksPage() {
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
|
||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button size="sm" disabled={!createName.trim() || creating} onClick={() => void handleCreate()}>
|
||||
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 重命名 Dialog ── */}
|
||||
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名笔记本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 flex-shrink-0 items-center justify-center text-3xl">
|
||||
{renameTarget?.avatar_url
|
||||
? <img src={renameTarget.avatar_url} alt="" className="size-10 rounded object-contain" />
|
||||
: <Files className="size-8 text-muted-foreground" />}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleRename(); }}
|
||||
disabled={renaming}
|
||||
className="flex-1 rounded-lg border-2 border-foreground bg-background px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{renameError && <p className="text-xs text-destructive">{renameError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={renaming} onClick={() => setRenameTarget(null)}>取消</Button>
|
||||
<Button size="sm" disabled={!renameName.trim() || renaming} onClick={() => void handleRename()}>
|
||||
{renaming ? <Loader2 className="size-4 animate-spin" /> : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 删除确认 Dialog ── */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除笔记本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||
</p>
|
||||
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={deleting} onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" size="sm" disabled={deleting} onClick={() => void handleDelete()}>
|
||||
{deleting ? <Loader2 className="size-4 animate-spin" /> : "删除"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,61 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { Toaster } from "sonner";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
|
||||
import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { RouteBaseProvider } from '@/core/route-base';
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminAgentsPage from "./AdminAgentsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminSkillsPage from "./AdminSkillsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminMemoryPage from "./AdminMemoryPage";
|
||||
import AdminUserHubPage from "./AdminUserHubPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||
import StudioNotebooksPage from "./StudioNotebooksPage";
|
||||
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
import NewAgentPage from './NewAgentPage';
|
||||
import AdminAgentsPage from './AdminAgentsPage';
|
||||
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
import AdminUserActivityPage from './AdminUserActivityPage';
|
||||
import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NotebookDetailPageWithKey() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
return <StudioNotebookDetailPage key={notebookId} />;
|
||||
}
|
||||
|
||||
function isInIframe(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
@@ -48,6 +63,8 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
wrapperOverflowHidden = false,
|
||||
insetOverflowHidden = false,
|
||||
pageSidebarDark = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
@@ -55,49 +72,43 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
wrapperOverflowHidden?: boolean;
|
||||
insetOverflowHidden?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? "0rem"
|
||||
? '0rem'
|
||||
: pageSidebarDark
|
||||
// dark page sidebar:
|
||||
// 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px
|
||||
// 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边;
|
||||
// 左下角浮动按钮是 fixed,叠在主内容上不影响 layout
|
||||
// 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致
|
||||
? (pageSidebarCollapsed ? "0rem" : "118px")
|
||||
? pageSidebarCollapsed
|
||||
? '0rem'
|
||||
: '118px'
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
? '4rem'
|
||||
: '16rem';
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
// 关键:把 workspace 侧栏从「锚定视口(fixed)」改成「锚定本布局容器(absolute)」。
|
||||
// 原 shadcn 原语用 fixed + left:var(--page-sidebar-width) + top:var(--header-height)
|
||||
// 把侧栏钉在视口坐标上 —— 在无界等「DOM 挂主文档、容器不占满整屏」的嵌套场景下,
|
||||
// fixed 会逃逸到整屏左侧、高度锁死为视口高度(即超出父元素 / 高度写死的根因)。
|
||||
//
|
||||
// 改 absolute 后侧栏改以本 wrapper(relative)为定位基准;又因外层 main 已经
|
||||
// pl-[--page-sidebar-width] 让开了页面侧栏、且 main 的 pl 恒等于 --page-sidebar-width,
|
||||
// 故这里 left/top 归零即与原 fixed 视觉位置逐像素一致;高度则跟随容器而非视口。
|
||||
// 作用域仅限本 SidebarProvider 实例(不动全局 ui/sidebar 原语),不影响 artifacts 等其它侧栏。
|
||||
className={[
|
||||
"relative h-full w-full overflow-x-hidden",
|
||||
"[&_[data-slot=sidebar-container]]:absolute",
|
||||
"[&_[data-slot=sidebar-container]]:left-0",
|
||||
"[&_[data-slot=sidebar-container]]:top-0",
|
||||
].join(" ")}
|
||||
'relative h-full w-full overflow-x-hidden',
|
||||
'[&_[data-slot=sidebar-container]]:absolute',
|
||||
'[&_[data-slot=sidebar-container]]:left-0',
|
||||
'[&_[data-slot=sidebar-container]]:top-0',
|
||||
].join(' ')}
|
||||
style={
|
||||
{
|
||||
"--page-sidebar-width": pageSidebarWidth,
|
||||
"--header-height": headerHidden ? "0rem" : "4rem",
|
||||
'--page-sidebar-width': pageSidebarWidth,
|
||||
'--header-height': headerHidden ? '0rem' : '4rem',
|
||||
} as CSSProperties
|
||||
}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen
|
||||
>
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</SidebarProvider>
|
||||
@@ -114,6 +125,30 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
};
|
||||
}) {
|
||||
if (isInIframe()) {
|
||||
return <Navigate to="/page/workspace/chats_iframe/new" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceRoutes({
|
||||
pageSidebarCollapsed = false,
|
||||
pageSidebarHidden = false,
|
||||
@@ -137,271 +172,306 @@ export default function WorkspaceRoutes({
|
||||
|
||||
return (
|
||||
<RouteBaseProvider value="/page/workspace">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="memory"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<MemoryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<StudioNotebooksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks/:notebookId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<StudioNotebookDetailPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable"
|
||||
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/planning"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<BusinessChainEditorPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Unified admin user hub */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Legacy redirects */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminScheduledTasksPage />
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats/new"
|
||||
element={<NewChatEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
wrapperOverflowHidden
|
||||
insetOverflowHidden
|
||||
>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<TagsPage />
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RecommendedQuestionsPage />
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptPrefixSettingsPage />
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<LlmMetricsPage />
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="memory"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<MemoryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<StudioNotebooksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks/:notebookId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<NotebookDetailPageWithKey />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable"
|
||||
element={
|
||||
<Navigate to="/page/workspace/roundtable/planning" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/planning"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<BusinessChainEditorPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Unified admin user hub */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Legacy redirects */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<TagsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RecommendedQuestionsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptPrefixSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<LlmMetricsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Suppress unused import warnings for pages referenced only in some branches
|
||||
void UsersPage;
|
||||
void AdminUserActivityPage;
|
||||
void AdminMemoryPage;
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseBrainyChatSubmitMessage } from "./iframe-chat-submit";
|
||||
|
||||
test("parses iframe text, page context, and screenshot into a prompt message", () => {
|
||||
const result = parseBrainyChatSubmitMessage({
|
||||
source: "brainy-extension",
|
||||
type: "brainy:chat-submit",
|
||||
requestId: "iframe-123",
|
||||
payload: {
|
||||
text: "提取并翻译",
|
||||
prompt: "提取图片中的文字并翻译成简体中文",
|
||||
pageContext: {
|
||||
title: "UN News",
|
||||
content: "Banning children from social media is not the answer.",
|
||||
url: "https://news.un.org/example",
|
||||
},
|
||||
screenshot: {
|
||||
dataUrl: "data:image/png;base64,aGVsbG8=",
|
||||
fileName: "capture.png",
|
||||
images: [
|
||||
{
|
||||
filename: "capture.png",
|
||||
url: "C:\\Users\\Rocky\\AppData\\Local\\Temp\\capture.png",
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.submitText, [
|
||||
"提取并翻译",
|
||||
].join("\n\n"));
|
||||
assert.deepEqual(result.iframeContext, { brainy_request_id: "iframe-123" });
|
||||
assert.equal(
|
||||
result.additionalKwargs?.prompt_prefix,
|
||||
[
|
||||
"<attached_image_priority>\nA screenshot image is attached for this message.\nUse the attached image as the primary source of truth.\nUse page context and text prompt only as supporting context.\nIf the image conflicts with page context or text, trust the image.\n</attached_image_priority>",
|
||||
"<extension_prompt>\n提取图片中的文字并翻译成简体中文\n</extension_prompt>",
|
||||
"<page_context>\n<title>\nUN News\n</title>\n\n<url>\nhttps://news.un.org/example\n</url>\n\n<content>\nBanning children from social media is not the answer.\n</content>\n</page_context>",
|
||||
].join("\n\n"),
|
||||
);
|
||||
assert.equal(result.files.length, 1);
|
||||
assert.equal(result.files[0]?.type, "file");
|
||||
assert.equal(result.files[0]?.filename, "capture.png");
|
||||
assert.equal(result.files[0]?.mediaType, "image/png");
|
||||
assert.ok(result.files[0]?.file instanceof File);
|
||||
assert.equal(result.files[0]?.file?.name, "capture.png");
|
||||
assert.equal(result.files[0]?.file?.type, "image/png");
|
||||
assert.equal(result.files[0]?.url, "data:image/png;base64,aGVsbG8=");
|
||||
});
|
||||
|
||||
test("keeps screenshot-only iframe submissions in the conversation flow", () => {
|
||||
const result = parseBrainyChatSubmitMessage({
|
||||
source: "brainy-extension",
|
||||
type: "brainy:chat-submit",
|
||||
payload: {
|
||||
screenshot: {
|
||||
dataUrl: "data:image/jpeg;base64,aGVsbG8=",
|
||||
images: [
|
||||
{
|
||||
filename: "shot.jpg",
|
||||
url: "C:\\Users\\Rocky\\AppData\\Local\\Temp\\shot.jpg",
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.submitText, "");
|
||||
assert.equal(
|
||||
result.additionalKwargs?.prompt_prefix,
|
||||
"<attached_image_priority>\nA screenshot image is attached for this message.\nUse the attached image as the primary source of truth.\nUse page context and text prompt only as supporting context.\nIf the image conflicts with page context or text, trust the image.\n</attached_image_priority>",
|
||||
);
|
||||
assert.equal(result.files.length, 1);
|
||||
assert.equal(result.files[0]?.filename, "shot.jpg");
|
||||
assert.equal(result.files[0]?.mediaType, "image/jpeg");
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
type BrainyPageContext = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type BrainyScreenshotPath = {
|
||||
image_url?: string;
|
||||
};
|
||||
|
||||
type BrainyScreenshotImage = {
|
||||
filename?: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type BrainyScreenshot = {
|
||||
dataUrl?: string;
|
||||
fileName?: string;
|
||||
filePath?: BrainyScreenshotPath[];
|
||||
images?: BrainyScreenshotImage[];
|
||||
};
|
||||
|
||||
export type BrainyChatSubmitMessage = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
payload?: {
|
||||
text?: string;
|
||||
prompt?: string;
|
||||
pageContext?: BrainyPageContext;
|
||||
screenshot?: BrainyScreenshot;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type IframePromptInputFilePart = {
|
||||
type: "file";
|
||||
url: string;
|
||||
mediaType: string;
|
||||
filename: string;
|
||||
file?: File;
|
||||
};
|
||||
|
||||
export type ParsedBrainyChatSubmitMessage = {
|
||||
submitText: string;
|
||||
files: IframePromptInputFilePart[];
|
||||
iframeContext?: Record<string, unknown>;
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const IMAGE_PRIORITY_INSTRUCTION = [
|
||||
"<attached_image_priority>",
|
||||
"A screenshot image is attached for this message.",
|
||||
"Use the attached image as the primary source of truth.",
|
||||
"Use page context and text prompt only as supporting context.",
|
||||
"If the image conflicts with page context or text, trust the image.",
|
||||
"</attached_image_priority>",
|
||||
].join("\n");
|
||||
|
||||
function trimToUndefined(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts.at(-1);
|
||||
}
|
||||
|
||||
function isLikelyLocalPath(path: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith("\\\\");
|
||||
}
|
||||
|
||||
function mimeTypeFromFilename(filename: string | undefined): string | undefined {
|
||||
const lower = filename?.toLowerCase() ?? "";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseDataUrlMeta(dataUrl: string): { mimeType: string; payload: string } | null {
|
||||
const match = /^data:([^;,]+)?(?:;base64)?,(.*)$/i.exec(dataUrl);
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1] || "application/octet-stream",
|
||||
payload: match[2] || "",
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBase64(base64: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Uint8Array.from(Buffer.from(base64, "base64"));
|
||||
}
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function fileFromDataUrl(
|
||||
dataUrl: string,
|
||||
filename: string,
|
||||
mediaType: string,
|
||||
): File | undefined {
|
||||
const meta = parseDataUrlMeta(dataUrl);
|
||||
if (!meta) return undefined;
|
||||
|
||||
const bytes = decodeBase64(meta.payload);
|
||||
const arrayBuffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
);
|
||||
|
||||
return new File([arrayBuffer], filename, {
|
||||
type: mediaType || meta.mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePageContext(
|
||||
pageContext: BrainyPageContext | undefined,
|
||||
): BrainyPageContext | undefined {
|
||||
if (!pageContext) return undefined;
|
||||
|
||||
const normalized = {
|
||||
title: trimToUndefined(pageContext.title),
|
||||
content: trimToUndefined(pageContext.content),
|
||||
url: trimToUndefined(pageContext.url),
|
||||
};
|
||||
|
||||
return normalized.title || normalized.content || normalized.url
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildIframePromptPrefix({
|
||||
prompt,
|
||||
pageContext,
|
||||
hasAttachedImage = false,
|
||||
}: {
|
||||
prompt?: string;
|
||||
pageContext?: BrainyPageContext;
|
||||
hasAttachedImage?: boolean;
|
||||
}) {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (hasAttachedImage) {
|
||||
sections.push(IMAGE_PRIORITY_INSTRUCTION);
|
||||
}
|
||||
|
||||
const normalizedPrompt = trimToUndefined(prompt);
|
||||
if (normalizedPrompt) {
|
||||
sections.push(
|
||||
`<extension_prompt>\n${normalizedPrompt}\n</extension_prompt>`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedPageContext = normalizePageContext(pageContext);
|
||||
if (normalizedPageContext) {
|
||||
const pageContextParts = [
|
||||
normalizedPageContext.title
|
||||
? `<title>\n${normalizedPageContext.title}\n</title>`
|
||||
: null,
|
||||
normalizedPageContext.url
|
||||
? `<url>\n${normalizedPageContext.url}\n</url>`
|
||||
: null,
|
||||
normalizedPageContext.content
|
||||
? `<content>\n${normalizedPageContext.content}\n</content>`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (pageContextParts.length > 0) {
|
||||
sections.push(
|
||||
`<page_context>\n${pageContextParts.join("\n\n")}\n</page_context>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildIframeMessageText({
|
||||
text,
|
||||
prompt,
|
||||
}: {
|
||||
text?: string;
|
||||
prompt?: string;
|
||||
}) {
|
||||
const primaryText = trimToUndefined(text) || trimToUndefined(prompt) || "";
|
||||
return primaryText;
|
||||
}
|
||||
|
||||
function buildScreenshotFileParts(
|
||||
screenshot: BrainyScreenshot | undefined,
|
||||
): IframePromptInputFilePart[] {
|
||||
if (!screenshot) return [];
|
||||
|
||||
const fallbackPath =
|
||||
screenshot.filePath?.map((item) => item.image_url).find(Boolean) ??
|
||||
screenshot.images?.map((item) => item.url).find(Boolean);
|
||||
const fallbackFilename =
|
||||
trimToUndefined(screenshot.fileName) ??
|
||||
screenshot.images?.map((item) => trimToUndefined(item.filename)).find(Boolean) ??
|
||||
fileNameFromPath(fallbackPath) ??
|
||||
"screenshot.png";
|
||||
|
||||
const dataUrl = trimToUndefined(screenshot.dataUrl);
|
||||
if (dataUrl) {
|
||||
const parsed = parseDataUrlMeta(dataUrl);
|
||||
const mediaType =
|
||||
parsed?.mimeType || mimeTypeFromFilename(fallbackFilename) || "image/png";
|
||||
const file = fileFromDataUrl(dataUrl, fallbackFilename, mediaType);
|
||||
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: dataUrl,
|
||||
mediaType,
|
||||
filename: fallbackFilename,
|
||||
...(file ? { file } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const remoteImage = screenshot.images?.find((item) => {
|
||||
const url = trimToUndefined(item.url);
|
||||
return !!url && !isLikelyLocalPath(url);
|
||||
});
|
||||
|
||||
if (remoteImage?.url && remoteImage.filename) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: remoteImage.url,
|
||||
mediaType:
|
||||
mimeTypeFromFilename(remoteImage.filename) || "application/octet-stream",
|
||||
filename: remoteImage.filename,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseBrainyChatSubmitMessage(
|
||||
data: BrainyChatSubmitMessage,
|
||||
): ParsedBrainyChatSubmitMessage {
|
||||
const text = trimToUndefined(data.payload?.text);
|
||||
const prompt = trimToUndefined(data.payload?.prompt);
|
||||
const pageContext = normalizePageContext(data.payload?.pageContext);
|
||||
const files = buildScreenshotFileParts(data.payload?.screenshot);
|
||||
const submitText = buildIframeMessageText({ text, prompt });
|
||||
const promptPrefix = buildIframePromptPrefix({
|
||||
prompt,
|
||||
pageContext,
|
||||
hasAttachedImage: files.length > 0,
|
||||
});
|
||||
const requestId = trimToUndefined(data.requestId);
|
||||
|
||||
return {
|
||||
submitText,
|
||||
files,
|
||||
...(requestId
|
||||
? { iframeContext: { brainy_request_id: requestId } }
|
||||
: {}),
|
||||
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user