Merge branch 'master' into dhk-1
This commit is contained in:
commit
85aa242fd5
48 files changed
+1818
-207
No files matched your search
@@ -30,6 +30,7 @@ import { useNotification } from "@/core/notification/hooks";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { env } from "@/env";
|
||||
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
|
||||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
@@ -157,6 +158,21 @@ export default function AgentChatPage({
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
// 操作日志(Y-Log):专家代理问答完成(成功)后,用最终汇总文本上报一条。
|
||||
try {
|
||||
const msgs = state.messages ?? [];
|
||||
const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai");
|
||||
const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human");
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "B1-CM助手-专家代理问答",
|
||||
url: `/api/threads/${threadId ?? ""}/runs/stream`,
|
||||
reqParam: { agent_id, question: lastHuman ? textOfMessage(lastHuman) : "" },
|
||||
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
|
||||
});
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -187,6 +188,21 @@ export default function ChatPage() {
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
// 操作日志(Y-Log):问答完成(成功)后,用最终汇总文本上报一条。
|
||||
try {
|
||||
const msgs = state.messages ?? [];
|
||||
const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai");
|
||||
const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human");
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "B1-CM助手-专家代理问答",
|
||||
url: `/api/threads/${threadId ?? ""}/runs/stream`,
|
||||
reqParam: { agent_id: selectedAgentId, question: lastHuman ? textOfMessage(lastHuman) : "" },
|
||||
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
|
||||
});
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
type BrainyChatSubmitMessage,
|
||||
parseBrainyChatSubmitMessage,
|
||||
} from "./iframe-chat-submit";
|
||||
import { resolveIframeAssistantId } from "./iframe-chat-agent";
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
|
||||
|
||||
type BrainyChatStatus = "started" | "finished";
|
||||
type BrainyAuthTokenMessage = {
|
||||
@@ -93,7 +95,6 @@ function postBrainyAuthToken(accessToken: string) {
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -152,11 +153,19 @@ export default function IframeChatPage() {
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const activeAssistantId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId: null,
|
||||
});
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
context: {
|
||||
...settings.context,
|
||||
...(activeAssistantId ? { agent_id: activeAssistantId } : {}),
|
||||
},
|
||||
isMock,
|
||||
assistantId: activeAssistantId,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
@@ -193,6 +202,22 @@ export default function IframeChatPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const threadMetadataAgentId = (() => {
|
||||
const metadata = (thread as { metadata?: Record<string, unknown> | null })
|
||||
.metadata;
|
||||
return typeof metadata?.agent_id === "string" ? metadata.agent_id : null;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedAgentId = resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
});
|
||||
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
|
||||
setSelectedAgentId(resolvedAgentId);
|
||||
}
|
||||
}, [selectedAgentId, threadMetadataAgentId]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
@@ -210,31 +235,15 @@ export default function IframeChatPage() {
|
||||
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;
|
||||
}
|
||||
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
|
||||
const extraContext: Record<string, unknown> = {};
|
||||
if (activeAssistantId) {
|
||||
extraContext.agent_id = activeAssistantId;
|
||||
}
|
||||
if (settings.context.writing_mode && writingArtifactPath) {
|
||||
extraContext.writing_artifact_path = writingArtifactPath;
|
||||
}
|
||||
@@ -250,10 +259,8 @@ export default function IframeChatPage() {
|
||||
);
|
||||
},
|
||||
[
|
||||
activeAssistantId,
|
||||
hasStartedConversation,
|
||||
isNewThread,
|
||||
router,
|
||||
selectedAgentId,
|
||||
sendMessage,
|
||||
settings.context.writing_mode,
|
||||
threadId,
|
||||
@@ -277,6 +284,8 @@ export default function IframeChatPage() {
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const contextCompressionEnabled =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
const proModeEnabled = settings.context.mode === "pro";
|
||||
|
||||
const toggleWritingMode = useCallback(() => {
|
||||
@@ -301,6 +310,12 @@ export default function IframeChatPage() {
|
||||
});
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
|
||||
const toggleContextCompression = useCallback(() => {
|
||||
setLocalSettings("display", {
|
||||
contextCompressionEnabled: !contextCompressionEnabled,
|
||||
});
|
||||
}, [contextCompressionEnabled, setLocalSettings]);
|
||||
|
||||
const toggleProMode = useCallback(() => {
|
||||
setSettings("context", {
|
||||
mode: proModeEnabled ? "flash" : "pro",
|
||||
@@ -333,28 +348,29 @@ export default function IframeChatPage() {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
if (data?.type === "brainy:chat-interrupt") {
|
||||
void handleInterrupt(data);
|
||||
void handleInterrupt(data as BrainyChatInterruptMessage);
|
||||
return;
|
||||
}
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
const submitMessage = data as BrainyChatSubmitMessage;
|
||||
|
||||
const { submitText, files, iframeContext, additionalKwargs } =
|
||||
parseBrainyChatSubmitMessage(data);
|
||||
parseBrainyChatSubmitMessage(submitMessage);
|
||||
|
||||
console.log("收到插件消息", {
|
||||
text: data.payload?.text,
|
||||
prompt: data.payload?.prompt,
|
||||
pageContext: data.payload?.pageContext,
|
||||
screenshot: data.payload?.screenshot,
|
||||
model: data.payload?.model,
|
||||
requestId: data.requestId,
|
||||
text: submitMessage.payload?.text,
|
||||
prompt: submitMessage.payload?.prompt,
|
||||
pageContext: submitMessage.payload?.pageContext,
|
||||
screenshot: submitMessage.payload?.screenshot,
|
||||
model: submitMessage.payload?.model,
|
||||
requestId: submitMessage.requestId,
|
||||
filesCount: files.length,
|
||||
iframeContext,
|
||||
});
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
pendingRequestIdRef.current = data.requestId ?? null;
|
||||
pendingRequestIdRef.current = submitMessage.requestId ?? null;
|
||||
|
||||
handleSubmit(
|
||||
{ text: submitText, files },
|
||||
@@ -371,41 +387,59 @@ export default function IframeChatPage() {
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const bottomBarHeightClass = "pb-24";
|
||||
const modeButtons = [
|
||||
{
|
||||
key: "writing",
|
||||
label: "写作",
|
||||
icon: FileTextIcon,
|
||||
active: !!settings.context.writing_mode,
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "记忆",
|
||||
icon: BrainCircuitIcon,
|
||||
active: settings.context.memory_injection_enabled ?? true,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
label: "参考文献",
|
||||
icon: BookMarkedIcon,
|
||||
active: ragModeEnabled,
|
||||
onClick: toggleRagMode,
|
||||
disabled: ragModeLocked,
|
||||
activeClassName: "text-emerald-600 hover:text-emerald-700",
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
label: "专业问答",
|
||||
icon: GraduationCapIcon,
|
||||
active: proModeEnabled,
|
||||
onClick: toggleProMode,
|
||||
activeClassName: "text-violet-600 hover:text-violet-700",
|
||||
},
|
||||
] as const;
|
||||
const modeButtons = buildIframeModeButtons({
|
||||
agentSelected: !!selectedAgentId,
|
||||
writingModeEnabled: !!settings.context.writing_mode,
|
||||
memoryInjectionEnabled: Boolean(
|
||||
settings.context.memory_injection_enabled ?? true,
|
||||
),
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}).map((button) => {
|
||||
switch (button.key) {
|
||||
case "agent":
|
||||
return button;
|
||||
case "writing":
|
||||
return {
|
||||
...button,
|
||||
icon: FileTextIcon,
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
};
|
||||
case "memory":
|
||||
return {
|
||||
...button,
|
||||
icon: BrainCircuitIcon,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
};
|
||||
case "references":
|
||||
return {
|
||||
...button,
|
||||
icon: BookMarkedIcon,
|
||||
onClick: toggleRagMode,
|
||||
activeClassName: "text-emerald-600 hover:text-emerald-700",
|
||||
};
|
||||
case "contextCompression":
|
||||
return {
|
||||
...button,
|
||||
icon: ShrinkIcon,
|
||||
onClick: toggleContextCompression,
|
||||
activeClassName: "text-violet-600 hover:text-violet-700",
|
||||
};
|
||||
case "pro":
|
||||
return {
|
||||
...button,
|
||||
icon: GraduationCapIcon,
|
||||
onClick: toggleProMode,
|
||||
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
|
||||
};
|
||||
default:
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
@@ -496,6 +530,19 @@ export default function IframeChatPage() {
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
|
||||
{modeButtons.map((button) => {
|
||||
if (button.key === "agent") {
|
||||
return (
|
||||
<AgentSelector
|
||||
key={button.key}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
disabled={isUploading}
|
||||
triggerVariant="icon"
|
||||
iconButtonClassName="size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
|
||||
iconActiveClassName="text-amber-600 hover:text-amber-700"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Icon = button.icon;
|
||||
return (
|
||||
<Tooltip key={button.key} content={button.label}>
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
} from '@/core/page-layout/sidebar-enabled';
|
||||
import { fetchYLogConfig } from '@/core/log/y-log-config';
|
||||
import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
@@ -160,6 +163,19 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
// 操作日志(Y-Log):只有 token 登录(存在 token1=authToken)才上报。
|
||||
// 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。
|
||||
if (authToken) {
|
||||
persistYLogToken(authToken);
|
||||
void fetchYLogConfig();
|
||||
writeYLog({
|
||||
optType: OptType.login,
|
||||
optDetail: 'B1-CM助手-登录',
|
||||
url: '/api/v1/auth/login/token',
|
||||
reqParam: { username: loginUserName || auth.user_id },
|
||||
returnParam: { user_id: auth.user_id },
|
||||
});
|
||||
}
|
||||
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
|
||||
setStoredUserName(loginUserName);
|
||||
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
|
||||
|
||||
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue'];
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
@@ -13,6 +16,17 @@ interface NotebookTokenData {
|
||||
export default function NotebookAIWritingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const themekey = searchParams.get('themekey');
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// 跟随调用方传入的 themekey 切换主题;缺省或非法值兜底为 light
|
||||
useEffect(() => {
|
||||
const next = themekey && VALID_THEMES.includes(themekey as ThemeType)
|
||||
? (themekey as ThemeType)
|
||||
: 'light';
|
||||
setTheme(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themekey]);
|
||||
|
||||
const [data, setData] = useState<NotebookTokenData | null>(null);
|
||||
const [spaceName, setSpaceName] = useState<string | null>(null);
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
AlertCircle,
|
||||
AlignLeft,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
BookOpen,
|
||||
Files,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -27,6 +29,7 @@ import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||
import type { SourceItem } from "@/core/studio/types";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
Select,
|
||||
@@ -118,6 +121,20 @@ function SourceCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── 上传中占位卡片 ──────────────────────────────────────
|
||||
function PendingSourceCard({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="group relative flex h-32 w-full flex-col justify-between rounded-2xl p-4 ring-1 ring-border/50 bg-muted/40 cursor-default">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-background">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-foreground">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 来源骨架屏 ──────────────────────────────────────────
|
||||
function SourceCardSkeleton() {
|
||||
return (
|
||||
@@ -136,6 +153,7 @@ function SourcesDialog({
|
||||
sources,
|
||||
isLoadingSources,
|
||||
selectedSourceIds,
|
||||
pendingSources,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
@@ -145,6 +163,7 @@ function SourcesDialog({
|
||||
sources: SourceItem[];
|
||||
isLoadingSources: boolean;
|
||||
selectedSourceIds: number[];
|
||||
pendingSources: Map<string, { id?: number; title: string }>;
|
||||
onToggle: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
@@ -188,15 +207,26 @@ function SourcesDialog({
|
||||
<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}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
: <>
|
||||
{Array.from(pendingSources.entries())
|
||||
.filter(([, s]) => !sources.some((src) => {
|
||||
const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? "";
|
||||
return srcTitle === s.title;
|
||||
}))
|
||||
.map(([taskId, s]) => (
|
||||
<PendingSourceCard key={taskId} title={s.title} />
|
||||
))}
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
@@ -218,6 +248,7 @@ export default function StudioNotebookDetailPage({
|
||||
const params = useParams<{ notebookId: string }>();
|
||||
const notebookId =
|
||||
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
|
||||
const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -229,6 +260,8 @@ export default function StudioNotebookDetailPage({
|
||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||
// 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配)
|
||||
const pendingSelectTitlesRef = useRef<Set<string>>(new Set());
|
||||
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||
const THREADS_LIMIT = 5;
|
||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||
@@ -253,6 +286,22 @@ export default function StudioNotebookDetailPage({
|
||||
setSelectedSourceIds(sources.map((s) => s.id));
|
||||
}
|
||||
}, [sources, notebook?.id, initializedForNotebook]);
|
||||
|
||||
// sources 刷新后,将轮询成功的来源(按标题匹配)自动选中
|
||||
useEffect(() => {
|
||||
if (pendingSelectTitlesRef.current.size === 0) return;
|
||||
const matched = sources.filter((s) => {
|
||||
const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
|
||||
return pendingSelectTitlesRef.current.has(title);
|
||||
});
|
||||
if (matched.length > 0) {
|
||||
setSelectedSourceIds((prev) => [...new Set([...prev, ...matched.map((s) => s.id)])]);
|
||||
matched.forEach((s) => {
|
||||
const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? "";
|
||||
pendingSelectTitlesRef.current.delete(title);
|
||||
});
|
||||
}
|
||||
}, [sources]);
|
||||
const citationRehypePlugins = useMemo(() => {
|
||||
const mapped = sources.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -297,6 +346,10 @@ export default function StudioNotebookDetailPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
void thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const handleSend = async (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
@@ -358,20 +411,17 @@ export default function StudioNotebookDetailPage({
|
||||
const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' };
|
||||
const taskStatus = data.status === 'failed' ? 'error' : data.status;
|
||||
if (taskStatus !== 'processing') {
|
||||
// 任务完成,从轮询队列移除并刷新来源列表
|
||||
// 先取出 source 信息,再从轮询队列移除
|
||||
const source = pendingSources.get(taskId);
|
||||
setPendingSources((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const source = newMap.get(taskId);
|
||||
newMap.delete(taskId);
|
||||
if (source) {
|
||||
if (taskStatus === 'success') {
|
||||
// toast.success(`来源 "${source.title}" 处理完成`);
|
||||
} else {
|
||||
// toast.error(`来源 "${source.title}" 处理失败`);
|
||||
}
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
// 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中
|
||||
if (taskStatus === 'success' && source?.title) {
|
||||
pendingSelectTitlesRef.current.add(source.title);
|
||||
}
|
||||
// 刷新来源列表
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] });
|
||||
// 刷新 notebooks 数据以更新 source_count
|
||||
@@ -381,7 +431,7 @@ export default function StudioNotebookDetailPage({
|
||||
} catch (error) {
|
||||
console.error(`轮询任务 ${taskId} 状态失败:`, error);
|
||||
}
|
||||
}, [notebook?.id, queryClient]);
|
||||
}, [notebook?.id, pendingSources, queryClient]);
|
||||
|
||||
// 控制轮询 - 只在对话框打开时轮询
|
||||
useEffect(() => {
|
||||
@@ -508,7 +558,7 @@ export default function StudioNotebookDetailPage({
|
||||
</div>
|
||||
<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`}
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
|
||||
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"
|
||||
@@ -532,7 +582,7 @@ export default function StudioNotebookDetailPage({
|
||||
<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`}
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat&themekey=${theme}`}
|
||||
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"
|
||||
@@ -583,7 +633,6 @@ export default function StudioNotebookDetailPage({
|
||||
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">
|
||||
@@ -598,6 +647,24 @@ export default function StudioNotebookDetailPage({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
title="停止回答"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</button>
|
||||
) : input.trim() ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="发送"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
@@ -711,7 +778,6 @@ export default function StudioNotebookDetailPage({
|
||||
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">
|
||||
@@ -726,6 +792,24 @@ export default function StudioNotebookDetailPage({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="shrink-0 rounded-full bg-muted p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
title="停止回答"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</button>
|
||||
) : input.trim() ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="发送"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -769,6 +853,7 @@ export default function StudioNotebookDetailPage({
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
pendingSources={pendingSources}
|
||||
onToggle={handleToggleSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import type { StudioNotebook } from "@/core/studio/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,13 +24,16 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function NotebookCardSkeleton() {
|
||||
function NotebookCardSkeleton({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<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={`flex flex-col rounded-[20px] p-5 ${isDark ? "bg-[#1e2330]" : "bg-[#f2f2f2]"}`}
|
||||
style={{ minHeight: 180 }}
|
||||
>
|
||||
<div className={`size-10 animate-pulse rounded-xl ${isDark ? "bg-[#2c3346]" : "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 className={`h-4 w-3/4 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
|
||||
<div className={`h-3 w-1/3 animate-pulse rounded ${isDark ? "bg-[#2c3346]" : "bg-[#e0e0e0]"}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -37,11 +41,13 @@ function NotebookCardSkeleton() {
|
||||
|
||||
function NotebookCard({
|
||||
nb,
|
||||
isDark,
|
||||
onRename,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
isDark: boolean;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
|
||||
@@ -61,16 +67,16 @@ function NotebookCard({
|
||||
<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]">
|
||||
<span className={`line-clamp-2 text-[15px] font-medium leading-snug ${isDark ? "text-[#e8eaf0]" : "text-[#1f1f1f]"}`}>
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||
<span className={`text-[13px] ${isDark ? "text-[#8b93a7]" : "text-[#666]"}`}>{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
<div className={`group relative flex flex-col rounded-[20px] transition-colors ${isDark ? "bg-[#1e2330] hover:bg-[#262c3b]" : "bg-[#f2f2f2] hover:bg-[#e8e8e8]"}`} style={{ minHeight: 180 }}>
|
||||
{onOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,6 +131,8 @@ export default function StudioNotebooksPage({
|
||||
onOpenNotebook?: (id: number) => void;
|
||||
} = {}) {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ── 新建 ──
|
||||
@@ -221,11 +229,12 @@ export default function StudioNotebooksPage({
|
||||
{/* 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} />)
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} isDark={isDark} />)
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
isDark={isDark}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
onOpen={onOpenNotebook}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
import { resolveIframeAssistantId } from "./iframe-chat-agent.ts";
|
||||
|
||||
test("prefers the currently selected iframe agent", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: "roundtable-structure",
|
||||
threadMetadataAgentId: "lead_agent",
|
||||
}),
|
||||
"roundtable-structure",
|
||||
);
|
||||
});
|
||||
|
||||
test("reuses the thread-bound agent when reopening an iframe thread", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: null,
|
||||
threadMetadataAgentId: "roundtable-structure",
|
||||
}),
|
||||
"roundtable-structure",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to lead-agent mode when no agent is selected or bound", () => {
|
||||
assert.equal(
|
||||
resolveIframeAssistantId({
|
||||
selectedAgentId: null,
|
||||
threadMetadataAgentId: null,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
type ResolveIframeAssistantIdInput = {
|
||||
selectedAgentId: string | null;
|
||||
threadMetadataAgentId: string | null;
|
||||
};
|
||||
|
||||
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
|
||||
const trimmed = agentId?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveIframeAssistantId({
|
||||
selectedAgentId,
|
||||
threadMetadataAgentId,
|
||||
}: ResolveIframeAssistantIdInput): string | undefined {
|
||||
return (
|
||||
normalizeAgentId(selectedAgentId) ??
|
||||
normalizeAgentId(threadMetadataAgentId)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
|
||||
|
||||
test("includes the context compression toggle in the iframe chat toolbar", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
proModeEnabled: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
buttons.map((button) => button.key),
|
||||
[
|
||||
"writing",
|
||||
"memory",
|
||||
"references",
|
||||
"contextCompression",
|
||||
"pro",
|
||||
"agent",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns only concrete toolbar button configs", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: true,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
proModeEnabled: false,
|
||||
});
|
||||
|
||||
assert.equal(buttons.every((button) => typeof button.key === "string"), true);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export type IframeModeButtonState = {
|
||||
key:
|
||||
| "agent"
|
||||
| "writing"
|
||||
| "memory"
|
||||
| "references"
|
||||
| "contextCompression"
|
||||
| "pro";
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type BuildIframeModeButtonsInput = {
|
||||
agentSelected: boolean;
|
||||
writingModeEnabled: boolean;
|
||||
memoryInjectionEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
ragModeLocked: boolean;
|
||||
contextCompressionEnabled: boolean;
|
||||
proModeEnabled: boolean;
|
||||
};
|
||||
|
||||
export function buildIframeModeButtons({
|
||||
agentSelected,
|
||||
writingModeEnabled,
|
||||
memoryInjectionEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
contextCompressionEnabled,
|
||||
proModeEnabled,
|
||||
}: BuildIframeModeButtonsInput): IframeModeButtonState[] {
|
||||
return [
|
||||
{
|
||||
key: "writing",
|
||||
label: "写作",
|
||||
active: writingModeEnabled,
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "记忆",
|
||||
active: memoryInjectionEnabled,
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
label: "参考文献",
|
||||
active: ragModeEnabled,
|
||||
disabled: ragModeLocked,
|
||||
},
|
||||
{
|
||||
key: "contextCompression",
|
||||
label: "上下文压缩",
|
||||
active: contextCompressionEnabled,
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
label: "专业问答",
|
||||
active: proModeEnabled,
|
||||
},
|
||||
{
|
||||
key: "agent",
|
||||
label: "智能体",
|
||||
active: agentSelected,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user