This commit is contained in:
fengxiangdebaozi committed 2026-06-03 18:54:26 +08:00
commit 8f6e08eb2f
102 files changed
+6816 -6016

No files matched your search

+5
View File
@@ -25,6 +25,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
@@ -32,3 +34,6 @@ VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# 我的笔记是否显示,侧边栏
VITE_STUDIO_ENABLED=false
+1
View File
@@ -62,6 +62,7 @@
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
@@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-[1.35rem] border border-[#ece7dd] bg-white shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
"flex flex-col overflow-hidden rounded-[1.35rem] border border-border bg-background shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
className,
)}
{...props}
@@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
"flex items-center justify-between border-b border-border bg-background px-4 py-3",
className,
)}
{...props}
@@ -147,7 +147,7 @@ export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
<div
ref={ref}
className={cn(
"min-h-0 flex-1 overflow-auto bg-white p-4",
"min-h-0 flex-1 overflow-auto bg-background p-4",
className,
)}
{...props}
@@ -2,6 +2,7 @@
import {
BotIcon,
CopyIcon,
GlobeIcon,
InfoIcon,
LockIcon,
@@ -43,12 +44,14 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useCreateAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -65,6 +68,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
@@ -153,6 +157,41 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleDuplicate() {
if (createAgent.isPending) {
return;
}
try {
// SOUL.md is omitted from the list payload (perf), so fetch the detail
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
} catch {
// keep the list fallback
}
const created = await createAgent.mutateAsync({
name: `${agent.name} 副本`,
description: agent.description ?? "",
soul,
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
is_private: false,
});
toast.success(
`已复制为「${desensitize(created.name)}」,可在「我的」中编辑`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleSaveEdit() {
const trimmedName = editName.trim();
if (!trimmedName) {
@@ -471,6 +510,16 @@ export function AgentCard({ agent }: AgentCardProps) {
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
@@ -535,7 +584,7 @@ export function AgentCard({ agent }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
@@ -543,13 +592,13 @@ export function AgentCard({ agent }: AgentCardProps) {
当前没有可用技能
</p>
) : (
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.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"
@@ -557,7 +606,7 @@ export function AgentCard({ agent }: AgentCardProps) {
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={desensitize(skill.description ?? "")}
className="h-8 max-w-[200px] rounded-r-none"
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">{desensitize(skill.name)}</span>
</Button>
@@ -16,7 +16,11 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
displayConfigFromResultSet,
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -63,9 +67,14 @@ function logReferenceDebug(
});
}
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
}) => {
const { thread } = useThread();
const pathname = usePathname();
@@ -271,12 +280,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}
// Update artifacts from the current thread
const nextArtifacts = thread.values.artifacts ?? [];
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts);
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
if (
!disableArtifacts &&
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
!selectedArtifact
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
@@ -291,6 +304,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
// }
if (
!disableArtifacts &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
@@ -307,14 +321,18 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
selectedArtifact,
setArtifacts,
thread.values.artifacts,
disableArtifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (disableArtifacts) {
return false;
}
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
}, [artifactsOpen, artifacts, disableArtifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences;
@@ -343,6 +361,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}
}, [sidePanelOpen]);
if (disableArtifacts) {
return <>{children}</>;
}
return (
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
@@ -362,14 +384,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
"bg-background transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
"h-full bg-background p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
@@ -603,9 +625,10 @@ function adaptBackendSourceWithSkillDisplay(
if (!match) {
return null;
}
const display = findDisplayConfigForBackendSource(source, match.display);
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
display,
)[0];
if (!normalized) {
return null;
@@ -614,13 +637,27 @@ function adaptBackendSourceWithSkillDisplay(
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
mode: display.mode,
tableColumns: display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findDisplayConfigForBackendSource(
source: BackendReferenceSource,
display: SkillDisplayConfig,
) {
const resultSetKey = source.resultSetKey;
if (resultSetKey) {
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
if (resultSet) {
return displayConfigFromResultSet(display, resultSet);
}
}
return { ...display, resultSets: [] };
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
@@ -583,7 +583,7 @@ export function InputBox({
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
: "rounded-3xl bg-background border border-border shadow-sm dark:bg-slate-800 dark:border-slate-700",
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
className,
)}
disabled={disabled}
@@ -258,12 +258,12 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
return (
@@ -1,33 +1,41 @@
import type { Message } from "@langchain/langgraph-sdk";
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import type { Message } from '@langchain/langgraph-sdk';
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from "react";
import rehypeKatex from "rehype-katex";
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from "@/components/ai-elements/loader";
import { Loader } from '@/components/ai-elements/loader';
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
MessageToolbar,
} from "@/components/ai-elements/message";
} from '@/components/ai-elements/message';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
} from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
UploadedFilePreviewDialog,
useAuthedObjectUrl,
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config';
import { useI18n } from '@/core/i18n/hooks';
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -35,22 +43,64 @@ import {
stripArtifactTag,
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
} from '@/core/messages/utils';
import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
} from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import { cn } from '@/lib/utils';
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button';
import { Tooltip } from '../tooltip';
import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from './context';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
type EmbeddedPageContext = {
title?: string;
url?: string;
content?: string;
};
function extractTagContent(source: string, tagName: string) {
const match = new RegExp(
`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`,
'i',
).exec(source);
return match?.[1]?.trim() ?? '';
}
function parseEmbeddedPageContext(source: string): {
content: string;
pageContext: EmbeddedPageContext | null;
} {
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
if (!match) {
return { content: source, pageContext: null };
}
const body = match[1] ?? '';
const pageContext = {
title: extractTagContent(body, 'title') || undefined,
url: extractTagContent(body, 'url') || undefined,
content: extractTagContent(body, 'content') || undefined,
};
const cleanedContent = source
.replace(match[0], '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!pageContext.title && !pageContext.url && !pageContext.content) {
return { content: cleanedContent, pageContext: null };
}
return { content: cleanedContent, pageContext };
}
/**
* Resolve an in-conversation file path to a fetchable URL.
@@ -64,7 +114,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
return useCallback(
(path: string) => {
if (publicShareCode) {
const normalized = path.startsWith("/") ? path : `/${path}`;
const normalized = path.startsWith('/') ? path : `/${path}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
@@ -77,50 +127,65 @@ function useArtifactResolver(threadId: string): (path: string) => string {
export function MessageListItem({
className,
fitRowToContent = false,
message,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
}: {
className?: string;
fitRowToContent?: boolean;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
}) {
const isHuman = message.type === "human";
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
"";
'';
return (
<AIElementMessage
className={cn("group/conversation-message relative w-full", className)}
from={isHuman ? "user" : "assistant"}
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
isHuman && fitRowToContent && 'ml-auto',
className,
)}
from={isHuman ? 'user' : 'assistant'}
>
<MessageContent
className={isHuman ? "w-fit" : "w-full"}
className={isHuman ? 'w-fit' : 'w-full'}
message={message}
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
/>
{!isLoading && (
{/*
Human messages carry their own inline copy/edit toolbar inside
MessageContent (above the timestamp). Only assistant answers use the
floating bottom toolbar here.
*/}
{!isLoading && !isHuman && (
<MessageToolbar
className={cn(
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
'-bottom-8',
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
)}
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{!isHuman && messageMarkdown && (
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
@@ -133,6 +198,128 @@ export function MessageListItem({
);
}
/**
* Inline copy/edit affordances + editor for the user's own question bubble.
*
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
* in normal flow above the timestamp (not floating) so it never covers the
* following message. Clicking edit turns the bubble into a textarea seeded with
* the question; confirming sends the edited text as a brand-new turn (the
* original question stays in the thread).
*/
function HumanMessageBlock({
text,
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
}: {
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(text);
useEffect(() => {
if (!editing) {
setDraft(text);
}
}, [text, editing]);
const confirmEdit = useCallback(() => {
const value = draft.trim();
if (!value) {
return;
}
onEditResubmit?.(value);
setEditing(false);
}, [draft, onEditResubmit]);
if (editing) {
return (
<div className="ml-auto flex w-full max-w-[min(85%,42rem)] flex-col gap-2">
{filesList}
<Textarea
value={draft}
autoFocus
onChange={(event) => setDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
confirmEdit();
}
if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
className="min-h-20 resize-none text-[15px] leading-7"
/>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
{t.common.confirm}
</Button>
</div>
</div>
);
}
const messageResponse = text ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{text}
</AIElementMessageResponse>
) : null;
return (
<div className="ml-auto flex flex-col items-end gap-1">
{filesList}
{messageResponse && (
<AIElementMessageContent className="w-fit">
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
{text && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
<CopyButton clipboardData={text} className="size-6" />
{onEditResubmit && (
<Tooltip content={t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setEditing(true)}
>
<PencilIcon size={11} />
</Button>
</Tooltip>
)}
</div>
)}
</div>
);
}
/**
* Custom image component that handles artifact URLs
*/
@@ -140,7 +327,7 @@ function MessageImage({
src,
alt,
threadId,
maxWidth = "90%",
maxWidth = '90%',
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
@@ -149,13 +336,13 @@ function MessageImage({
const resolveArtifact = useArtifactResolver(threadId);
if (!src) return null;
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
if (typeof src !== "string") {
if (typeof src !== 'string') {
return <img className={imgClassName} src={src} alt={alt} {...props} />;
}
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
return (
<a href={url} target="_blank" rel="noopener noreferrer">
@@ -172,6 +359,7 @@ function MessageContent_({
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
}: {
className?: string;
message: Message;
@@ -180,14 +368,30 @@ function MessageContent_({
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
// Streamdown splits content into per-block processors; calling the factory
// once here ensures all blocks share the same transformer instance (and thus
// the same citation counter) for this message.
const stableExtraPlugins = useMemo(() => {
if (!extraRehypePlugins?.length) return [];
return extraRehypePlugins.map((factory) => {
const transformer = factory();
return () => transformer;
});
}, [extraRehypePlugins]);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
() =>
extraRehypePlugins?.length
? [...baseRehypePlugins, ...extraRehypePlugins]
: baseRehypePlugins,
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human";
const isHuman = message.type === 'human';
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
() => ({
@@ -195,13 +399,16 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
if (href?.startsWith('#reference-')) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
if (
typeof props.children === 'string' &&
/^citation:/i.test(props.children)
) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
if (href?.startsWith('/mnt/')) {
const url = resolveArtifact(href);
return (
<a
@@ -229,7 +436,7 @@ function MessageContent_({
const files = useMemo(() => {
const files = message.additional_kwargs?.files;
if (!Array.isArray(files) || files.length === 0) {
if (rawContent.includes("<uploaded_files>")) {
if (rawContent.includes('<uploaded_files>')) {
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
return parseUploadedFiles(rawContent);
}
@@ -240,10 +447,19 @@ function MessageContent_({
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
return applyInlineCitations(rawContent ?? "", displaySources);
return applyInlineCitations(rawContent ?? '', displaySources);
}, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo(
() =>
isHuman
? parseEmbeddedPageContext(contentToDisplay)
: { content: contentToDisplay, pageContext: null },
[contentToDisplay, isHuman],
);
const filesList =
files && files.length > 0 ? (
@@ -251,7 +467,7 @@ function MessageContent_({
) : null;
// Uploading state: mock AI message shown while files upload
if (message.additional_kwargs?.element === "task") {
if (message.additional_kwargs?.element === 'task') {
return (
<AIElementMessageContent className={className}>
<Task defaultOpen={false}>
@@ -284,26 +500,15 @@ function MessageContent_({
}
if (isHuman) {
const messageResponse = contentToDisplay ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{contentToDisplay}
</AIElementMessageResponse>
) : null;
return (
<div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList}
{messageResponse && (
<AIElementMessageContent className="w-fit">
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
</div>
<HumanMessageBlock
text={parsedHumanContent.content}
pageContext={parsedHumanContent.pageContext}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
/>
);
}
@@ -316,7 +521,7 @@ function MessageContent_({
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
</div>
@@ -332,6 +537,27 @@ function MessageContent_({
);
}
function EmbeddedPageContextCard({
pageContext,
}: {
pageContext: EmbeddedPageContext;
}) {
const previewText = [pageContext.title, pageContext.url, pageContext.content]
.filter(Boolean)
.join(' | ');
return (
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
<div
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
title={previewText}
>
{previewText}
</div>
</div>
);
}
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
@@ -340,10 +566,10 @@ function MessageContent_({
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
if (typeof clientTs !== 'string' || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const pad = (n: number) => String(n).padStart(2, '0');
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -356,17 +582,17 @@ function formatAskedTime(
*/
function MessageTimeStamp({
time,
align = "right",
align = 'right',
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
align?: 'left' | 'right';
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
'text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums',
align === 'right' ? 'justify-end pr-0.5' : 'justify-start pl-0.5',
)}
title={time.full}
>
@@ -380,39 +606,39 @@ function MessageTimeStamp({
* Get file extension and check helpers
*/
const getFileExt = (filename: string) =>
filename.split(".").pop()?.toLowerCase() ?? "";
filename.split('.').pop()?.toLowerCase() ?? '';
const FILE_TYPE_MAP: Record<string, string> = {
json: "JSON",
csv: "CSV",
txt: "TXT",
md: "Markdown",
py: "Python",
js: "JavaScript",
ts: "TypeScript",
tsx: "TSX",
jsx: "JSX",
html: "HTML",
css: "CSS",
xml: "XML",
yaml: "YAML",
yml: "YAML",
pdf: "PDF",
png: "PNG",
jpg: "JPG",
jpeg: "JPEG",
gif: "GIF",
svg: "SVG",
zip: "ZIP",
tar: "TAR",
gz: "GZ",
json: 'JSON',
csv: 'CSV',
txt: 'TXT',
md: 'Markdown',
py: 'Python',
js: 'JavaScript',
ts: 'TypeScript',
tsx: 'TSX',
jsx: 'JSX',
html: 'HTML',
css: 'CSS',
xml: 'XML',
yaml: 'YAML',
yml: 'YAML',
pdf: 'PDF',
png: 'PNG',
jpg: 'JPG',
jpeg: 'JPEG',
gif: 'GIF',
svg: 'SVG',
zip: 'ZIP',
tar: 'TAR',
gz: 'GZ',
};
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
function getFileTypeLabel(filename: string): string {
const ext = getFileExt(filename);
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE");
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
}
function isImageFile(filename: string): boolean {
@@ -423,7 +649,7 @@ function isImageFile(filename: string): boolean {
* Format bytes to human-readable size string
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
if (bytes === 0) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
@@ -465,8 +691,17 @@ function RichFileCard({
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const isUploading = file.status === "uploading";
const [previewOpen, setPreviewOpen] = useState(false);
const isUploading = file.status === 'uploading';
const isImage = isImageFile(file.filename);
const fileUrl = file.path ? resolveArtifact(file.path) : null;
// Thumbnails must be fetched with auth: the artifact route is gated and a
// plain <img src> sends no Authorization header → 401 (broken image). Fetch
// the bytes via apiFetch and display the resulting object URL instead.
const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl(
fileUrl,
isImage && !isUploading && !!fileUrl,
);
if (isUploading) {
return (
@@ -495,50 +730,81 @@ function RichFileCard({
);
}
if (!file.path) return null;
if (!fileUrl) return null;
const fileUrl = resolveArtifact(file.path);
const preview = (
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
);
if (isImage) {
return (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="group border-border/40 relative block overflow-hidden rounded-lg border"
>
<img
src={fileUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
</a>
<>
<button
type="button"
onClick={() => setPreviewOpen(true)}
title={file.filename}
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="bg-muted/40 text-muted-foreground flex h-32 w-44 items-center justify-center gap-2 text-xs">
{thumbLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<>
<FileIcon className="size-4" />
<span className="truncate">{file.filename}</span>
</>
)}
</div>
)}
</button>
{preview}
</>
);
}
return (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</div>
<>
<button
type="button"
onClick={() => setPreviewOpen(true)}
title={file.filename}
className="bg-background border-border/40 hover:bg-accent/50 flex max-w-50 min-w-30 cursor-pointer flex-col gap-1 rounded-lg border p-3 text-left shadow-sm transition-colors"
>
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</button>
{preview}
</>
);
}
@@ -556,8 +822,8 @@ function AssistantDisplayContent({
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const { parts, placedKeys } = useMemo(
() =>
@@ -573,14 +839,14 @@ function AssistantDisplayContent({
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
(group) => group.mode !== 'citation' && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
part.type === 'markdown' ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
@@ -609,11 +875,11 @@ function AssistantDisplayContent({
}
type DisplayPart =
| { type: "markdown"; content: string }
| { type: 'markdown'; content: string }
| {
type: "display";
type: 'display';
key: string;
mode: RuntimeDisplaySource["mode"];
mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[];
};
@@ -630,16 +896,16 @@ function splitSkillDisplayPlaceholders(
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
parts.push({ type: 'markdown', content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const attrs = parseDisplayAttrs(match[1] ?? '');
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({
type: "display",
type: 'display',
key: group.key,
mode: group.mode,
sources: group.sources,
@@ -649,26 +915,29 @@ function splitSkillDisplayPlaceholders(
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
parts.push({ type: 'markdown', content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
function parseDisplayAttrs(raw: string): {
tool?: string;
mode?: RuntimeDisplaySource['mode'];
} {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
mode: attrs.mode as RuntimeDisplaySource['mode'] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
mode?: RuntimeDisplaySource['mode'],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
@@ -689,20 +958,18 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
const group = groups.get(key) ?? {
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
@@ -710,5 +977,5 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
@@ -52,24 +52,30 @@ export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit,
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
@@ -222,7 +228,12 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
@@ -240,12 +251,14 @@ export function MessageList({
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
/>
);
});
@@ -0,0 +1,278 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " +
"[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const TEXT_EXT = [
"txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml",
"xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java",
"go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env",
];
// Office documents are converted to a sibling Markdown file server-side
// (uploads.auto_convert_documents). We preview that .md instead of the binary.
const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"];
function extOf(filename: string): string {
const i = filename.lastIndexOf(".");
return i >= 0 ? filename.slice(i + 1).toLowerCase() : "";
}
type PreviewKind = "image" | "text" | "office" | "pdf" | "binary";
function kindOf(filename: string): PreviewKind {
const ext = extOf(filename);
if (IMAGE_EXT.includes(ext)) return "image";
if (ext === "pdf") return "pdf";
if (OFFICE_EXT.includes(ext)) return "office";
if (TEXT_EXT.includes(ext)) return "text";
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled || !url) {
setObjectUrl(null);
return;
}
let cancelled = false;
let created: string | null = null;
setLoading(true);
setError(null);
apiFetch(url)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
if (cancelled) return;
created = URL.createObjectURL(blob);
setObjectUrl(created);
})
.catch((err: unknown) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
return { objectUrl, loading, error };
}
/**
* Preview dialog for a file attached to a chat message. All fetches go through
* `apiFetch` so the `Authorization` header is attached (artifacts are auth-gated).
*
* - image → fetched as blob, rendered inline
* - text → fetched and shown (markdown rendered for .md, otherwise <pre>)
* - office → fetches the server-converted sibling `<name>.md` and renders it
* - pdf → fetched as blob, embedded via <iframe>
* - else → download link
*/
export function UploadedFilePreviewDialog({
filename,
fileUrl,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
[kind, fileUrl],
);
const [text, setText] = useState<string | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setTextLoading(false);
});
return () => {
cancelled = true;
};
}, [open, kind, textUrl]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
const loadingView = (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{kind === "image" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<div className="flex items-center justify-center">
<img
src={objectUrl}
alt={filename}
className="max-h-full max-w-full rounded border"
/>
</div>
)
) : kind === "pdf" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<iframe
src={objectUrl}
title={filename}
className="h-full min-h-[500px] w-full rounded border"
/>
)
) : kind === "text" || kind === "office" ? (
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
)}
</div>
</DialogContent>
</Dialog>
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</div>
);
}
@@ -7,6 +7,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
CopyIcon,
FileArchive,
GlobeIcon,
InfoIcon,
@@ -50,6 +51,7 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -61,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useDuplicateSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned,
@@ -390,9 +393,12 @@ export function SkillRow({
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState("");
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -428,6 +434,31 @@ export function SkillRow({
}
}
function handleOpenDuplicate() {
// Suggest a "<name>-copy" default that already satisfies the backend's
// lowercase/digit/-/_ skill-name rule. The user can edit it before saving.
setDuplicateName(`${skill.name}-copy`);
setDuplicateOpen(true);
}
async function handleDuplicate() {
const target = duplicateName.trim();
if (!target) {
toast.error("请输入新技能名称");
return;
}
try {
const result = await duplicateSkillMutation.mutateAsync({
sourceName: skill.name,
newName: target,
});
toast.success(`已复制为「${result.skill_name}」`);
setDuplicateOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePinned() {
const next = !skill.pinned;
try {
@@ -587,6 +618,16 @@ export function SkillRow({
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -600,6 +641,43 @@ export function SkillRow({
)}
</ItemActions>
</Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>复制技能</DialogTitle>
<DialogDescription>
将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。
</DialogDescription>
</DialogHeader>
<Input
value={duplicateName}
onChange={(e) => setDuplicateName(e.currentTarget.value)}
placeholder="new-skill-name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleDuplicate();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDuplicateOpen(false)}
disabled={duplicateSkillMutation.isPending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void handleDuplicate()}
disabled={duplicateSkillMutation.isPending}
>
{duplicateSkillMutation.isPending ? t.common.loading : "复制"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
@@ -17,6 +17,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
displayConfigFromResultSet,
emptyDisplayConfig,
inferDisplayConfig,
normalizeDisplaySources,
@@ -27,9 +28,29 @@ import {
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
import type {
Skill,
SkillDisplayConfig,
SkillDisplayMode,
SkillDisplayResultSetConfig,
SkillLinkStrategy,
} from "@/core/skills/type";
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
type DisplayFieldKey =
| "resultPath"
| "titleField"
| "summaryField"
| "contentField"
| "sourceField"
| "authorField"
| "typeField"
| "timeField"
| "scoreField"
| "urlField"
| "idField"
| "linkTemplate";
const fieldLabels: Array<[DisplayFieldKey, string]> = [
["resultPath", "结果列表路径"],
["titleField", "标题字段"],
["summaryField", "摘要字段"],
@@ -71,14 +92,56 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
return display?.resultSets?.[0]?.key ?? null;
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
firstResultSetKey(skill.display),
);
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
const [tableColumnsText, setTableColumnsText] = useState(() => {
const display = skill.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
});
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
const resultSets = draft.resultSets ?? [];
const activeResultSet =
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
const editingConfig = activeResultSet ?? draft;
const previewConfig = activeResultSet
? displayConfigFromResultSet(draft, activeResultSet)
: draft;
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
key: K,
value: SkillDisplayResultSetConfig[K],
) {
if (!activeResultSet) {
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
return;
}
setDraft({
...draft,
resultSets: resultSets.map((resultSet) =>
resultSet.key === activeResultSet.key
? { ...resultSet, [key]: value }
: resultSet,
),
});
}
function selectResultSet(key: string) {
const resultSet = resultSets.find((item) => item.key === key) ?? null;
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
}
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
@@ -88,7 +151,9 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
useEffect(() => {
const display = skill.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
const resultSet = display.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
useEffect(() => {
@@ -101,8 +166,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
return;
}
const display = fresh.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(fresh.displaySample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
@@ -186,8 +253,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}, [sampleText]);
const previewItems = useMemo(() => (
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
), [draft, samplePayload]);
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
), [previewConfig, samplePayload]);
function generateDraft() {
if (!samplePayload) {
@@ -196,8 +263,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已生成展示适配草稿");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
: "已生成展示适配草稿",
);
}
async function loadSampleAndGenerate() {
@@ -219,8 +292,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
);
const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已获取样例并生成展示适配");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
: "已获取样例并生成展示适配",
);
} catch (err) {
generateDraft();
toast.error(err instanceof Error ? err.message : String(err));
@@ -235,8 +314,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
sample: samplePayload ?? undefined,
});
const display = saved.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存");
} catch (err) {
@@ -248,6 +329,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig());
setActiveResultSetKey(null);
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
} catch (err) {
@@ -283,8 +365,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">展示方式</span>
<Select
value={draft.mode}
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
value={editingConfig.mode}
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">跳转方式</span>
<Select
value={draft.linkStrategy}
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
value={editingConfig.linkStrategy}
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -320,26 +402,58 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
type="number"
min={1}
max={100}
value={draft.maxItems}
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
value={editingConfig.maxItems}
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
/>
</label>
</div>
{resultSets.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">结果集配置</span>
<Select
value={activeResultSet?.key ?? resultSets[0]?.key}
onValueChange={selectResultSet}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resultSets.map((resultSet) => (
<SelectItem key={resultSet.key} value={resultSet.key}>
{resultSet.label || resultSet.resultPath}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{activeResultSet && (
<label className="flex items-center justify-end gap-2 pb-2">
<span className="text-xs text-muted-foreground">启用当前结果集</span>
<Switch
checked={activeResultSet.enabled}
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
/>
</label>
)}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{fieldLabels.map(([field, label]) => (
<label key={field} className="space-y-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
value={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
/>
</label>
))}
</div>
{draft.mode === "table" && (
{editingConfig.mode === "table" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
<Textarea
@@ -349,7 +463,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
const value = JSON.parse(event.currentTarget.value);
if (Array.isArray(value)) {
setDraft(updateConfig(draft, "tableColumns", value));
updateEditingConfig("tableColumns", value);
}
} catch {
// Keep typing invalid JSON without crashing the panel.
@@ -361,12 +475,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
</label>
)}
{draft.mode === "custom" && (
{editingConfig.mode === "custom" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">自定义展示描述</span>
<Textarea
value={draft.customInstruction}
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
value={editingConfig.customInstruction}
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
className="min-h-24"
/>
@@ -1,9 +1,10 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { desensitize } from "@/lib/desensitize";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
@@ -16,16 +17,18 @@ export function ThreadTitle({
threadId: string;
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = thread.values.title;
} else if (isNewThread) {
_title = t.pages.newChat;
}
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = displayTitle;
} else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
@@ -35,17 +38,18 @@ export function ThreadTitle({
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
]);
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
]);
if (!thread.values?.title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{desensitize(thread.values.title ?? "Untitled")}
</FlipDisplay>
);
}
return (
<FlipDisplay uniqueKey={threadId}>
{desensitize(displayTitle)}
</FlipDisplay>
);
}
@@ -19,6 +19,9 @@ import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenL
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
import { env } from "@/env";
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -165,7 +168,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
</>
);
}
@@ -34,8 +34,6 @@ const NAV_ITEM_CLASS =
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
const SIDEBAR_PREVIEW_COUNT = 5;
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
@@ -71,6 +69,7 @@ export function WorkspaceNavStudio() {
return null;
}
const SIDEBAR_PREVIEW_COUNT = 2;
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
return (
+19 -17
View File
@@ -2,26 +2,28 @@
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth";
import { getLangGraphBaseURL } from "../config";
import { sanitizeRunStreamOptions } from "./stream-mode";
import { getAuthorizationHeaderValue } from "../auth";
import { getLangGraphBaseURL } from "../config";
import { withCsrfHeaders } from "./csrf";
import { sanitizeRunStreamOptions } from "./stream-mode";
function createCompatibleClient(isMock?: boolean): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
onRequest(_url, init) {
const headers = new Headers(init.headers ?? {});
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return {
...init,
headers,
};
},
});
apiUrl: getLangGraphBaseURL(isMock),
onRequest(_url, init) {
const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return {
...init,
credentials: init.credentials ?? "include",
headers,
};
},
});
const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = ((threadId, assistantId, payload) =>
+7 -2
View File
@@ -5,6 +5,7 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth";
import { getCanvasLangGraphBaseURL } from "../config";
import { withCsrfHeaders } from "./csrf";
import { sanitizeRunStreamOptions } from "./stream-mode";
/**
@@ -19,12 +20,16 @@ function createCanvasClient(): LangGraphClient {
const client = new LangGraphClient({
apiUrl: getCanvasLangGraphBaseURL(),
onRequest(_url, init) {
const headers = new Headers(init.headers ?? {});
const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader);
}
return { ...init, headers };
return {
...init,
credentials: init.credentials ?? "include",
headers,
};
},
});
+42
View File
@@ -0,0 +1,42 @@
const CSRF_COOKIE_NAME = "csrf_token";
const CSRF_HEADER_NAME = "X-CSRF-Token";
function readCookie(name: string): string | undefined {
if (typeof document === "undefined") {
return undefined;
}
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [rawName, ...rest] = cookie.trim().split("=");
if (rawName !== name) {
continue;
}
const rawValue = rest.join("=");
if (!rawValue) {
return undefined;
}
try {
return decodeURIComponent(rawValue);
} catch {
return rawValue;
}
}
return undefined;
}
export function getCsrfToken(): string | undefined {
const token = readCookie(CSRF_COOKIE_NAME)?.trim();
return token ? token : undefined;
}
export function withCsrfHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers ?? {});
const csrfToken = getCsrfToken();
if (csrfToken && !next.has(CSRF_HEADER_NAME)) {
next.set(CSRF_HEADER_NAME, csrfToken);
}
return next;
}
+17 -15
View File
@@ -1,18 +1,20 @@
import { getAuthorizationHeaderValue } from "@/core/auth";
function withAuthHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers ?? {});
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !next.has("Authorization")) {
next.set("Authorization", authHeader);
}
return next;
import { getAuthorizationHeaderValue } from "@/core/auth";
import { withCsrfHeaders } from "./csrf";
function withAuthHeaders(headers?: HeadersInit): Headers {
const next = withCsrfHeaders(headers);
const authHeader = getAuthorizationHeaderValue();
if (authHeader && !next.has("Authorization")) {
next.set("Authorization", authHeader);
}
return next;
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
return fetch(input, {
...init,
headers: withAuthHeaders(init?.headers),
});
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
return fetch(input, {
...init,
credentials: init?.credentials ?? "include",
headers: withAuthHeaders(init?.headers),
});
}
+52 -6
View File
@@ -17,24 +17,70 @@ export async function listAllUsers(): Promise<UserListItem[]> {
return res.json() as Promise<UserListItem[]>;
}
// Normalize a FastAPI error body into a human-readable string. `detail` may be
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
// array of validation errors — anything but a string would otherwise render as
// the dreaded "[object Object]".
function extractErrorMessage(body: unknown, fallback = "Login failed."): string {
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const msg = detail
.map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d)))
.filter(Boolean)
.join("; ");
return msg || fallback;
}
if (detail && typeof detail === "object") {
const obj = detail as { message?: string; msg?: string };
return obj.message ?? obj.msg ?? fallback;
}
return fallback;
}
export async function loginByUsername(
username: string,
password?: string | null,
): Promise<LoginByUsernameResponse> {
// When the username-login password gate is enabled server-side, the shared
// secret must be passed as ?password=. Omitted when no password is provided
// so the passwordless flow keeps working unchanged.
const base = `${getBackendBaseURL()}/api/v1/auth/login/username`;
const url =
password != null && password !== ""
? `${base}?password=${encodeURIComponent(password)}`
: base;
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
});
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
}
return response.json() as Promise<LoginByUsernameResponse>;
}
export async function loginByToken(
token: string,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
const response = await apiFetch(
`${getBackendBaseURL()}/api/v1/auth/login/username`,
`${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Login failed." }));
throw new Error(error.detail ?? "Login failed.");
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
}
return response.json() as Promise<LoginByUsernameResponse>;
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
}
}
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
@@ -41,6 +41,7 @@ export const enUS: Translations = {
code: "Code",
preview: "Preview",
cancel: "Cancel",
confirm: "Confirm",
save: "Save",
install: "Install",
create: "Create",
@@ -80,6 +81,7 @@ export const enUS: Translations = {
// Input Box
inputBox: {
placeholder: "How can I assist you today?",
editQuestion: "Edit question",
createSkillPrompt:
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
addAttachments: "Add attachments",
@@ -42,6 +42,7 @@ export interface Translations {
code: string;
preview: string;
cancel: string;
confirm: string;
save: string;
install: string;
create: string;
@@ -77,6 +78,7 @@ export interface Translations {
// Input Box
inputBox: {
placeholder: string;
editQuestion: string;
createSkillPrompt: string;
addAttachments: string;
mode: string;
@@ -41,6 +41,7 @@ export const zhCN: Translations = {
code: "代码",
preview: "预览",
cancel: "取消",
confirm: "确定",
save: "保存",
install: "安装",
create: "创建",
@@ -80,6 +81,7 @@ export const zhCN: Translations = {
// Input Box
inputBox: {
placeholder: "今天我能为你做些什么?",
editQuestion: "编辑问题",
createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
addAttachments: "添加附件",
+4 -4
View File
@@ -482,10 +482,10 @@ export function parseUploadedFiles(content: string): FileInMessage[] {
return [];
}
// Check if the backend reported no new files were uploaded in this message
if (uploadedFilesContent?.includes("(empty)")) {
return [];
}
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
// the "uploaded in this message" section; files uploaded in previous messages
// are still listed below it. Bailing dropped those valid entries. The file
// regex below naturally skips the "(empty)" line, so parsing stays correct.
// Parse file list
// Format: - filename (size)\n Path: /path/to/file
+2 -2
View File
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
const citeCounter = new Map<number, number>();
let citeIndex = 0;
return (tree: Root) => {
visit(tree, "text", (node, index, parent) => {
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
CITE_RE.lastIndex = 0;
const newNodes: ElementContent[] = [];
const citeCounter = new Map<number, number>();
let citeIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
@@ -2,12 +2,15 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
HtmlPageFavorite,
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
ScheduledTaskExecutionContext,
ScheduledTaskResultFileContent,
ScheduledTaskRun,
ScheduledTaskRunFileUpdateResponse,
ScheduledTaskRunHtml,
ScheduledTaskRunMessagesResponse,
ScheduledTaskSubscription,
SchedulerThread,
@@ -52,6 +55,7 @@ export async function createScheduledTask(data: {
timezone?: string | null;
execution_agent_name?: string | null;
require_markdown?: boolean;
execution_context?: ScheduledTaskExecutionContext | null;
}) {
return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), {
@@ -212,6 +216,7 @@ export async function updateScheduledTask(
enabled: boolean;
execution_agent_name: string | null;
require_markdown: boolean;
execution_context: ScheduledTaskExecutionContext | null;
}>,
) {
return parseResponse<ScheduledTask>(
@@ -368,3 +373,47 @@ export async function adminTriggerScheduledTask(taskId: string) {
}),
);
}
// --- HTML page tasks ---
/** Fetch the generated HTML document for an ``html_page`` run. */
export async function getScheduledTaskRunHtml(runId: string) {
return parseResponse<ScheduledTaskRunHtml>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/html`),
);
}
const favoritesUrl = () => `${getBackendBaseURL()}/api/html-page-favorites`;
export async function listHtmlPageFavorites() {
return parseResponse<HtmlPageFavorite[]>(await apiFetch(favoritesUrl()));
}
export async function getHtmlPageFavorite(favoriteId: string) {
return parseResponse<HtmlPageFavorite>(
await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`),
);
}
export async function createHtmlPageFavorite(data: {
page_id: string;
title?: string | null;
description?: string | null;
tags?: string[];
}) {
return parseResponse<HtmlPageFavorite>(
await apiFetch(favoritesUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
export async function deleteHtmlPageFavorite(favoriteId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`, {
method: "DELETE",
}),
);
}
@@ -10,14 +10,18 @@ import {
adminListScheduledTaskRuns,
adminTriggerScheduledTask,
adminUpdateScheduledTask,
createHtmlPageFavorite,
createScheduledTask,
deleteHtmlPageFavorite,
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
getScheduledTaskRunHtml,
getScheduledTaskRunMessages,
listAllScheduledTasks,
listHtmlPageFavorites,
listPublishedScheduledTasks,
listScheduledTaskRuns,
listScheduledTasks,
@@ -112,6 +116,42 @@ export function useScheduledTaskRunMessages(
});
}
/** Generated HTML for an ``html_page`` run; polls while the run is in flight. */
export function useScheduledTaskRunHtml(
runId?: string | null,
options: { enabled?: boolean; live?: boolean } = {},
) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId, "html"],
queryFn: () => getScheduledTaskRunHtml(runId!),
enabled: !!runId && (options.enabled ?? true),
refetchOnWindowFocus: false,
retry: false,
refetchInterval: options.live ? 5000 : false,
});
}
/** The current user's bookmarked HTML pages. */
export function useHtmlPageFavorites(enabled: boolean = true) {
return useQuery({
queryKey: ["html-page-favorites"],
queryFn: listHtmlPageFavorites,
enabled,
refetchOnWindowFocus: false,
});
}
/** Create / delete HTML page favorites. */
export function useHtmlPageFavoriteActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["html-page-favorites"] });
return {
create: useMutation({ mutationFn: createHtmlPageFavorite, onSuccess: invalidate }),
remove: useMutation({ mutationFn: deleteHtmlPageFavorite, onSuccess: invalidate }),
};
}
export function useScheduledTaskDeliveryProfile() {
return useQuery({
queryKey: ["scheduled-tasks", "delivery-profile"],
+59 -6
View File
@@ -11,6 +11,26 @@ export type ScheduledTaskSubscription = {
updated_at?: string | null;
};
/** Task kinds. A bare/absent kind is the regular agent task. */
export type ScheduledTaskKind = "agent" | "html_page";
/** Config for an ``html_page`` task, persisted inside ``execution_context``.
* Data gathering is delegated to the agent's own search skills, so there is no
* explicit search query / data source — the page prompt drives everything. */
export type HtmlPageConfig = {
page_prompt?: string | null;
negative_prompt?: string | null;
reference_favorite_id?: string | null;
model_name?: string | null;
};
export type ScheduledTaskExecutionContext = {
require_markdown?: boolean;
task_kind?: ScheduledTaskKind;
html_config?: HtmlPageConfig;
[key: string]: unknown;
};
export type ScheduledTask = {
task_id: string;
name: string;
@@ -21,11 +41,11 @@ export type ScheduledTask = {
timezone: string;
enabled: boolean;
/**
* Free-form execution context persisted with the task. ``require_markdown``
* lives here (rather than a dedicated column) so the feature needs no schema
* migration.
* Free-form execution context persisted with the task. ``require_markdown``,
* ``task_kind`` and ``html_config`` live here (rather than dedicated columns)
* so the feature needs no schema migration.
*/
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
execution_context?: ScheduledTaskExecutionContext | null;
next_run_at?: string | null;
last_run_at?: string | null;
last_status?: string | null;
@@ -70,7 +90,7 @@ export type ScheduledTaskResultFile = {
path?: string | null;
mime_type?: string | null;
size?: number | null;
kind?: "markdown" | "attachment" | "artifact" | null;
kind?: "markdown" | "attachment" | "artifact" | "html" | null;
};
export type ScheduledTaskResultFileContent = {
@@ -86,9 +106,42 @@ export type ScheduledTaskResultFileContent = {
export type ScheduledTaskRunResult = {
title: string;
content: string;
format: "markdown" | "text" | "json" | "mixed";
format: "markdown" | "text" | "json" | "mixed" | "html";
files: ScheduledTaskResultFile[];
created_at: string;
/** Present for ``html_page`` runs: the full generated HTML document. */
html?: string | null;
style_summary?: string | null;
layout_summary?: string | null;
};
/** Response of the run/latest HTML preview endpoints. */
export type ScheduledTaskRunHtml = {
run_id: string;
task_id?: string | null;
title?: string | null;
html?: string | null;
style_summary?: string | null;
layout_summary?: string | null;
page_id?: string | null;
created_at?: string | null;
};
/** A user-bookmarked generated HTML page. */
export type HtmlPageFavorite = {
id: string;
user_id: string;
page_id: string;
title: string;
description?: string | null;
tags?: string[];
html_snapshot?: string;
style_summary?: string | null;
layout_summary?: string | null;
content_structure_summary?: string | null;
reuse_prompt?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
export type ScheduledTaskRun = {
+6 -1
View File
@@ -1,3 +1,8 @@
export { useLocalSettings, useThreadSettings } from "./hooks";
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
export {
getLastAgentId,
isThreadRagModeLocked,
lockThreadRagMode,
saveLastAgentId,
} from "./local";
export type { LocalSettings } from "./local";
+24
View File
@@ -21,6 +21,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
export const LAST_AGENT_ID_KEY = "deerflow.last-agent-id";
function isBrowser(): boolean {
return typeof window !== "undefined";
@@ -148,6 +149,29 @@ export function applyThreadModelOverride(
};
}
/**
* The id of the agent the user last started a conversation with. New-chat
* landing screens pre-select this agent so the user doesn't have to re-pick it
* every time. Returns ``null`` when nothing was stored yet.
*/
export function getLastAgentId(): string | null {
if (!isBrowser()) {
return null;
}
return localStorage.getItem(LAST_AGENT_ID_KEY) || null;
}
export function saveLastAgentId(agentId: string | null | undefined) {
if (!isBrowser()) {
return;
}
if (!agentId) {
localStorage.removeItem(LAST_AGENT_ID_KEY);
return;
}
localStorage.setItem(LAST_AGENT_ID_KEY, agentId);
}
export function getLocalSettings(): LocalSettings {
if (!isBrowser()) {
return DEFAULT_LOCAL_SETTINGS;
@@ -1,4 +1,7 @@
import type { SkillDisplayConfig } from "@/core/skills/type";
import type {
SkillDisplayConfig,
SkillDisplayResultSetConfig,
} from "@/core/skills/type";
const DEFAULT_CITATION_MAX_ITEMS = 100;
const DEFAULT_LIST_MAX_ITEMS = 20;
@@ -15,6 +18,10 @@ export interface DisplaySourceItem {
url?: string;
content?: string;
score?: number;
displayMode?: SkillDisplayConfig["mode"];
resultSetKey?: string;
resultSetLabel?: string | null;
tableColumns?: SkillDisplayConfig["tableColumns"];
raw: Record<string, unknown>;
}
@@ -38,6 +45,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
maxItems: DEFAULT_CITATION_MAX_ITEMS,
customInstruction: "",
tableColumns: [],
resultSets: [],
});
const candidateFields: Record<string, string[]> = {
@@ -53,7 +61,7 @@ const candidateFields: Record<string, string[]> = {
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
};
function getByPath(value: unknown, path: string): unknown {
function getByPath(value: unknown, path: string | null | undefined): unknown {
if (!path) return value;
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
@@ -63,19 +71,55 @@ function getByPath(value: unknown, path: string): unknown {
}, value);
}
function findFirstArray(
value: unknown,
path = "",
): { path: string; items: Record<string, unknown>[] } | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
return { path, items: value as Record<string, unknown>[] };
export type ResultArrayCandidate = {
key: string;
label: string;
path: string;
items: Record<string, unknown>[];
};
export function findResultArrayCandidates(samplePayload: unknown): ResultArrayCandidate[] {
const candidates: ResultArrayCandidate[] = [];
const seen = new Set<string>();
function visit(value: unknown, path = "", depth = 0) {
if (depth > 8 || candidates.length >= 20) return;
if (Array.isArray(value)) {
const objectItems = value.filter(
(item): item is Record<string, unknown> =>
!!item && typeof item === "object" && !Array.isArray(item),
);
if (
(value.length === 0 || objectItems.length > 0) &&
objectItems.length === value.length &&
!seen.has(path)
) {
seen.add(path);
candidates.push({
key: buildResultSetKey(path || "root"),
label: buildResultSetLabel(path || "根数组", objectItems.length),
path,
items: objectItems,
});
}
return;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const [key, child] of Object.entries(value)) {
visit(child, path ? `${path}.${key}` : key, depth + 1);
}
}
if (!value || typeof value !== "object") return null;
for (const [key, child] of Object.entries(value)) {
const found = findFirstArray(child, path ? `${path}.${key}` : key);
if (found) return found;
}
return null;
visit(samplePayload);
return candidates;
}
function buildResultSetKey(path: string): string {
return path.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "root";
}
function buildResultSetLabel(path: string, count: number): string {
return `${path}(${count} 条)`;
}
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
@@ -93,13 +137,31 @@ function pickField(sample: Record<string, unknown>, candidates: string[]): strin
}
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
const sample = found.items[0] ?? {};
const candidates = findResultArrayCandidates(samplePayload);
const found = candidates[0] ?? {
key: "results",
label: "results(0 条)",
path: "results",
items: [],
};
const inferred = buildConfigFromCandidate(found, skillName);
inferred.resultSets =
candidates.length > 1
? candidates.map((candidate) => buildResultSetConfigFromCandidate(candidate, skillName))
: [];
return inferred;
}
function buildConfigFromCandidate(
candidate: ResultArrayCandidate,
skillName?: string,
): SkillDisplayConfig {
const sample = candidate.items[0] ?? {};
const inferred = emptyDisplayConfig();
inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results";
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
inferred.resultPath = candidate.path || "results";
inferred.maxItems = inferMaxItems(candidate.items.length, inferred.mode);
for (const [target, candidates] of Object.entries(candidateFields)) {
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
@@ -123,6 +185,65 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
return inferred;
}
function buildResultSetConfigFromCandidate(
candidate: ResultArrayCandidate,
skillName?: string,
): SkillDisplayResultSetConfig {
const config = buildConfigFromCandidate(candidate, skillName);
return {
key: candidate.key,
label: candidate.label,
enabled: true,
mode: config.mode,
resultPath: config.resultPath,
titleField: config.titleField,
summaryField: config.summaryField,
contentField: config.contentField,
sourceField: config.sourceField,
authorField: config.authorField,
typeField: config.typeField,
timeField: config.timeField,
scoreField: config.scoreField,
urlField: config.urlField,
idField: config.idField,
linkStrategy: config.linkStrategy,
linkTemplate: config.linkTemplate,
citationToken: config.citationToken,
maxItems: config.maxItems,
customInstruction: config.customInstruction,
tableColumns: config.tableColumns,
};
}
export function displayConfigFromResultSet(
base: SkillDisplayConfig,
resultSet: SkillDisplayResultSetConfig,
): SkillDisplayConfig {
return {
...base,
enabled: resultSet.enabled,
mode: resultSet.mode,
resultPath: resultSet.resultPath,
titleField: resultSet.titleField,
summaryField: resultSet.summaryField,
contentField: resultSet.contentField,
sourceField: resultSet.sourceField,
authorField: resultSet.authorField,
typeField: resultSet.typeField,
timeField: resultSet.timeField,
scoreField: resultSet.scoreField,
urlField: resultSet.urlField,
idField: resultSet.idField,
linkStrategy: resultSet.linkStrategy,
linkTemplate: resultSet.linkTemplate,
citationToken: resultSet.citationToken,
maxItems: resultSet.maxItems,
customInstruction: resultSet.customInstruction,
tableColumns: resultSet.tableColumns,
resultSets: [],
};
}
function buildDefaultTableColumns(
sample: Record<string, unknown>,
config: SkillDisplayConfig,
@@ -161,13 +282,13 @@ function stringify(value: unknown, fallback = ""): string {
function renderTemplate(template: string, item: Record<string, unknown>): string {
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
encodeURIComponent(stringify(item[key])),
encodeURIComponent(stringify(getByPath(item, key.trim()))),
);
}
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
const url = config.urlField ? stringify(item[config.urlField]) : "";
const id = config.idField ? stringify(item[config.idField]) : "";
const url = config.urlField ? stringify(getByPath(item, config.urlField)) : "";
const id = config.idField ? stringify(getByPath(item, config.idField)) : "";
if (config.linkStrategy === "url") return url || undefined;
if (config.linkStrategy === "id") {
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
@@ -176,6 +297,30 @@ function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig):
}
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
const configuredResultSets = config.resultSets ?? [];
const resultSets = configuredResultSets.filter(
(resultSet) => resultSet.enabled && resultSet.mode !== "none",
);
if (configuredResultSets.length > 0) {
return resultSets.flatMap((resultSet) =>
normalizeDisplaySourcesForConfig(
payload,
displayConfigFromResultSet(config, resultSet),
resultSet,
),
);
}
if (!config.enabled || config.mode === "none") {
return [];
}
return normalizeDisplaySourcesForConfig(payload, config);
}
function normalizeDisplaySourcesForConfig(
payload: unknown,
config: SkillDisplayConfig,
resultSet?: SkillDisplayResultSetConfig,
): DisplaySourceItem[] {
const rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return [];
const maxItems = resolveMaxItems(config, rawItems.length);
@@ -183,19 +328,23 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, maxItems)
.map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
author: config.authorField ? stringify(item[config.authorField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined,
id: config.idField ? stringify(getByPath(item, config.idField)) : undefined,
title: config.titleField ? stringify(getByPath(item, config.titleField), `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(getByPath(item, config.typeField), "result") : "result",
snippet: config.summaryField ? stringify(getByPath(item, config.summaryField)) : "",
content: config.contentField ? stringify(getByPath(item, config.contentField)) : undefined,
source: config.sourceField ? stringify(getByPath(item, config.sourceField)) : undefined,
author: config.authorField ? stringify(getByPath(item, config.authorField)) : undefined,
time: config.timeField ? stringify(getByPath(item, config.timeField)) : undefined,
score:
config.scoreField && typeof item[config.scoreField] === "number"
? (item[config.scoreField] as number)
config.scoreField && typeof getByPath(item, config.scoreField) === "number"
? (getByPath(item, config.scoreField) as number)
: undefined,
url: resolveLink(item, config),
displayMode: config.mode,
resultSetKey: resultSet?.key,
resultSetLabel: resultSet?.label,
tableColumns: config.tableColumns,
raw: item,
}));
}
@@ -202,8 +202,8 @@ export function collectRuntimeDisplaySources(
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolCall.name,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
mode: item.displayMode ?? effectiveMode,
tableColumns: item.tableColumns ?? match?.display.tableColumns,
})),
seenCitationKeys,
() => ++citationIndex,
@@ -242,8 +242,8 @@ export function collectRuntimeDisplaySources(
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolName,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
mode: item.displayMode ?? effectiveMode,
tableColumns: item.tableColumns ?? match?.display.tableColumns,
})),
seenCitationKeys,
() => ++citationIndex,
+18
View File
@@ -202,6 +202,24 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
): Promise<InstallSkillResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(sourceName)}/duplicate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to duplicate skill");
}
return response.json() as Promise<InstallSkillResponse>;
}
export interface ReconcileCustomSkillsResponse {
adopted: string[];
already_registered: string[];
+17
View File
@@ -7,6 +7,7 @@ import {
import {
deleteCustomSkill,
duplicateSkill,
enableSkill,
getSkill,
getSkillContent,
@@ -192,6 +193,22 @@ export function useSetSkillPinned() {
});
}
export function useDuplicateSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
sourceName,
newName,
}: {
sourceName: string;
newName: string;
}) => duplicateSkill(sourceName, newName),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDeleteCustomSkill() {
const queryClient = useQueryClient();
return useMutation({
+7 -1
View File
@@ -12,7 +12,9 @@ export interface SkillDisplayTableColumn {
width?: string | null;
}
export interface SkillDisplayConfig {
export interface SkillDisplayResultSetConfig {
key: string;
label?: string | null;
enabled: boolean;
mode: SkillDisplayMode;
resultPath: string;
@@ -34,6 +36,10 @@ export interface SkillDisplayConfig {
tableColumns?: SkillDisplayTableColumn[];
}
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
resultSets?: SkillDisplayResultSetConfig[];
}
export interface Skill {
name: string;
description: string;
+68 -6
View File
@@ -7,7 +7,7 @@ function base(): string {
export async function fetchStudioToken(email: string): Promise<string> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
@@ -22,7 +22,7 @@ export async function fetchStudioToken(email: string): Promise<string> {
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` },
@@ -36,7 +36,7 @@ export async function createSearchSpace(
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
method: "POST",
@@ -46,7 +46,47 @@ export async function createSearchSpace(
},
body: JSON.stringify({ name, description: "", citations_enabled: true }),
});
if (!res.ok) throw new Error(`创建失败:${res.status}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `创建失败:${res.status}`);
}
}
export async function uploadSourceText(
notebookId: number,
content: string,
title: string,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`删除失败:${res.status}`);
}
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
}
export interface ChunkRead {
@@ -69,7 +109,7 @@ export async function fetchDocumentByChunk(
token: string,
): Promise<DocumentWithChunks> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
headers: { Authorization: `Bearer ${token}` },
@@ -78,12 +118,34 @@ export async function fetchDocumentByChunk(
return res.json() as Promise<DocumentWithChunks>;
}
export async function uploadSourceFiles(
notebookId: number,
files: File[],
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const formData = new FormData();
formData.append("search_space_id", String(notebookId));
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${apiBase}/api/v1/documents/fileupload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function fetchNotebookSources(
notebookId: number,
token: string,
): Promise<SourceItem[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
+9 -2
View File
@@ -12,6 +12,7 @@ export function useStudioNotebooks(): {
notebooks: StudioNotebook[];
isLoading: boolean;
isError: boolean;
token: string | undefined;
} {
const email = getStoredAuth()?.email ?? "";
@@ -35,6 +36,7 @@ export function useStudioNotebooks(): {
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
isError: tokenQuery.isError || notebooksQuery.isError,
token: tokenQuery.data,
};
}
@@ -89,10 +91,15 @@ export function useNotebookChat(
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
// API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) =>
ids
.map((id) => sources.find((s) => s.id === id)?.document?.id)
.filter((id): id is number => id !== undefined);
const sourceIds =
selectedSourceIds.length > 0
? selectedSourceIds
: sources.map((s) => s.id).filter(Boolean);
? toDocIds(selectedSourceIds)
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
+6 -3
View File
@@ -350,9 +350,12 @@ export function useThreadStream({
// agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : "";
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: "/page/workspace/chats/new";
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
// First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
+12
View File
@@ -65,3 +65,15 @@ export function textOfMessage(message: Message) {
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
const cleaned = title
.replace(/<\/?[^>\n]+>/g, " ")
.replace(/<[^>\n]*$/g, " ")
.replace(/\s+/g, " ")
.trim();
return cleaned || "Untitled";
}
+6 -3
View File
@@ -6,9 +6,11 @@ function readEnv(
| "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL"
| "VITE_NOTEBOOK_API_BASE_URL"
| "VITE_AIPI_BACKENP_URL"
| "VITE_AIPI_FRONTEND_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN",
| "VITE_GITHUB_OAUTH_TOKEN"
| "VITE_STUDIO_ENABLED",
): EnvValue {
const value = import.meta.env[key];
return typeof value === "string" && value.trim() ? value : undefined;
@@ -21,7 +23,8 @@ export const env = {
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
};
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -11,6 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useModels } from '@/core/models/hooks';
import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
@@ -32,12 +33,13 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
const MAX_INTENT_LENGTH = 500;
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
const MATERIAL_SOURCE_OPTIONS: Array<{ value: 'knowledge_base' | 'skill'; label: string }> = [
{ value: 'knowledge_base', label: '知识库' },
{ value: 'skill', label: '技能检索' },
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
@@ -60,7 +62,13 @@ export function AIWritingForm({
materialSource, setMaterialSource,
modelName, setModelName,
} = useWritingForm();
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含)
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -112,6 +120,25 @@ export function AIWritingForm({
}
}
function handleNotebookChange(val: string) {
setSelectedNotebookId(val ? Number(val) : null);
setSelectedDocumentIds([]);
}
function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val);
if (val !== 'notebook') {
setSelectedNotebookId(null);
setSelectedDocumentIds([]);
}
}
function toggleDocument(docId: number) {
setSelectedDocumentIds((prev) =>
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId],
);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
@@ -128,6 +155,9 @@ export function AIWritingForm({
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的笔记"才传笔记参数
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -237,7 +267,7 @@ export function AIWritingForm({
<label className={FIELD_LABEL_CLASS}>素材来源</label>
<Select
value={materialSource}
onValueChange={(v) => setMaterialSource(v as 'knowledge_base' | 'skill')}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
@@ -247,13 +277,64 @@ export function AIWritingForm({
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground/80">
知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。
</p>
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="mt-4 space-y-2">
@@ -31,8 +31,8 @@ export interface WritingFormValue {
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill';
setMaterialSource: (v: 'knowledge_base' | 'skill') => void;
materialSource: 'knowledge_base' | 'skill' | 'notebook';
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -61,7 +61,7 @@ export function WritingFormProvider({ children }: { children: ReactNode }) {
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill'>('knowledge_base');
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -7,7 +7,7 @@ export interface AIMaterial {
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet' | 'skill';
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
url?: string;
recUuid?: string;
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
@@ -230,8 +230,12 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose';
/** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */
materialSource: 'knowledge_base' | 'skill';
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
materialSource: 'knowledge_base' | 'skill' | 'notebook';
/** 笔记来源(可选):笔记本 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
noteSourceIds?: number[];
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
+5 -1
View File
@@ -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>
+33 -3
View File
@@ -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
}
+327
View File
@@ -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>
);
}
+73 -43
View File
@@ -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>
);
}
+3 -3
View File
@@ -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
+258 -60
View File
@@ -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>
);
}
+209 -53
View File
@@ -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>
);
}
+384 -314
View File
@@ -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 } } : {}),
};
}
@@ -1,13 +1,15 @@
/**
* Resolve whether option cards should allow multi-select.
* Default is single-select (click to send); only multi when explicitly requested.
*
* Product decision: ALL clarification prompts are multi-select. Clicking an
* option only toggles its selection; the conversation continues only after the
* user presses the confirm button. We intentionally ignore the backend
* `allow_multiple` hint (and the old single-select "click to send" fast path)
* so the user is never forced into picking exactly one option.
*/
export function resolveAllowMultiple(
allowMultiple: boolean | undefined,
_allowMultiple: boolean | undefined,
_clarificationType?: string | undefined,
): boolean {
if (typeof allowMultiple === "boolean") {
return allowMultiple;
}
return false;
return true;
}