Merge branch 'xuhd' into 'master'
技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径 See merge request knowledge-base-bj/deerflow-server!83
This commit is contained in:
commit
d0e71a8104
68 files changed
+3501
-5331
No files matched your search
@@ -98,3 +98,6 @@ docs/aidev-docs
|
||||
docs/backups
|
||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||
!frontend-web/src/components/workspace/skills/
|
||||
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
@@ -25,8 +25,13 @@ 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
|
||||
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
|
||||
@@ -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>
|
||||
|
||||
@@ -384,14 +384,14 @@ const ChatBox: React.FC<{
|
||||
/>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Message } from '@langchain/langgraph-sdk';
|
||||
import { ClockIcon, FileIcon, Loader2Icon } from 'lucide-react';
|
||||
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
} 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,
|
||||
@@ -51,6 +54,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
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';
|
||||
@@ -130,6 +134,7 @@ export function MessageListItem({
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
@@ -139,6 +144,7 @@ export function MessageListItem({
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
}) {
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
@@ -163,17 +169,23 @@ export function MessageListItem({
|
||||
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',
|
||||
'-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"
|
||||
@@ -186,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
|
||||
*/
|
||||
@@ -225,6 +359,7 @@ function MessageContent_({
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
@@ -233,8 +368,21 @@ 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
|
||||
@@ -352,36 +500,15 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
const messageResponse = parsedHumanContent.content ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{contentToDisplay}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-auto flex max-w-full min-w-0 flex-col items-end gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{filesList}
|
||||
{messageResponse && (
|
||||
<AIElementMessageContent className="min-w-0 max-w-full w-fit">
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
{parsedHumanContent.pageContext ? (
|
||||
<EmbeddedPageContextCard
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
/>
|
||||
) : null}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
</div>
|
||||
<HumanMessageBlock
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export function MessageList({
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
}: {
|
||||
@@ -72,6 +73,7 @@ export function MessageList({
|
||||
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>;
|
||||
}) {
|
||||
@@ -251,6 +253,7 @@ export function MessageList({
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: "添加附件",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,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`,
|
||||
|
||||
@@ -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,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"),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -31,13 +32,14 @@ const INPUT_CLASS =
|
||||
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
|
||||
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
|
||||
const DEFAULT_MATERIAL_SOURCE: MaterialSource = 'knowledge_base';
|
||||
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]';
|
||||
@@ -54,10 +56,15 @@ export function AIWritingForm({
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] =
|
||||
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
|
||||
const [materialSource, setMaterialSource] = useState<MaterialSource>(DEFAULT_MATERIAL_SOURCE);
|
||||
const [modelName, setModelName] = useState('');
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
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();
|
||||
|
||||
@@ -109,6 +116,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;
|
||||
@@ -124,6 +150,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,7 +225,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}>
|
||||
@@ -206,13 +235,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">
|
||||
|
||||
@@ -25,6 +25,8 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
import { fetchStudioToken } from '@/core/studio/api';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
@@ -739,6 +741,19 @@ export function useAIWriting() {
|
||||
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||
dispatch({ type: 'start', request });
|
||||
try {
|
||||
// 如果选择了笔记来源,获取 notebook token 用于后端检索
|
||||
let notebookToken = '';
|
||||
if (request.noteSearchSpaceId) {
|
||||
const email = getStoredAuth()?.email ?? '';
|
||||
if (email) {
|
||||
try {
|
||||
notebookToken = await fetchStudioToken(email);
|
||||
} catch {
|
||||
// 忽略 token 获取失败,后端会优雅降级
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
@@ -758,9 +773,16 @@ export function useAIWriting() {
|
||||
current_draft: null,
|
||||
review_result: null,
|
||||
last_user_intervention: null,
|
||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||
notebook_source_ids: request.noteSourceIds ?? [],
|
||||
} as Partial<AIWritingValues>,
|
||||
{
|
||||
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
||||
config: {
|
||||
configurable: {
|
||||
model_name: request.modelName ?? '',
|
||||
...(notebookToken ? { notebook_token: notebookToken } : {}),
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 等)
|
||||
@@ -228,8 +228,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[];
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -114,6 +114,9 @@ export default function AgentChatPage() {
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasStartedConversation(true);
|
||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||
saveLastAgentId(agent_id);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
@@ -216,6 +219,7 @@ export default function AgentChatPage() {
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,10 +23,15 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import {
|
||||
getLastAgentId,
|
||||
saveLastAgentId,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -47,6 +52,8 @@ export default function ChatPage() {
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -58,10 +65,22 @@ export default function ChatPage() {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedAgentId(null);
|
||||
// Pre-select the agent the user last started a conversation with so a
|
||||
// fresh chat defaults to it. Validated against the selector list below.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
// Drop a restored last-used agent that is no longer in the selector list
|
||||
// (deleted / unpublished / no longer curated), so we never hand off to a
|
||||
// dead agent. Wait for the list to load before deciding.
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) return;
|
||||
if (!selectorAgents.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
@@ -184,6 +203,14 @@ export default function ChatPage() {
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
// Persist the picked agent the moment it changes so the next new chat
|
||||
// defaults to it — even if the user never sends a message this session.
|
||||
// Passing null (the ✕ clear) wipes the stored preference.
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
@@ -248,6 +275,9 @@ export default function ChatPage() {
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) =>
|
||||
handleSubmit({ text, files: [] })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,7 +335,7 @@ export default function ChatPage() {
|
||||
isLandingState
|
||||
? {
|
||||
selectedAgentId,
|
||||
onAgentChange: setSelectedAgentId,
|
||||
onAgentChange: handleAgentChange,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -545,13 +545,13 @@ export default function NewAgentPage() {
|
||||
没有匹配的技能。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="inline-flex max-w-full items-center"
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -560,7 +560,7 @@ export default function NewAgentPage() {
|
||||
onClick={() => toggleSkill(skill)}
|
||||
title={skill.description}
|
||||
className={cn(
|
||||
"h-8 max-w-[200px] rounded-r-none",
|
||||
"h-8 min-w-0 flex-1 rounded-r-none",
|
||||
!checked && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -68,9 +68,11 @@ type PublicRunFile = {
|
||||
type PublicRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
format: "markdown" | "text" | "json" | "mixed" | "html";
|
||||
files: PublicRunFile[];
|
||||
created_at: string;
|
||||
/** Present for ``html_page`` runs: the full generated HTML document. */
|
||||
html?: string | null;
|
||||
};
|
||||
|
||||
type PublicRun = {
|
||||
@@ -126,6 +128,12 @@ async function fetchPublicRunFile(runId: string, index: number): Promise<PublicF
|
||||
);
|
||||
}
|
||||
|
||||
type PublicRunHtml = { run_id: string; title?: string | null; html?: string | null };
|
||||
|
||||
async function fetchPublicRunHtml(runId: string): Promise<PublicRunHtml> {
|
||||
return fetchJson<PublicRunHtml>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/html`);
|
||||
}
|
||||
|
||||
async function updatePublicRunFile(
|
||||
runId: string,
|
||||
index: number,
|
||||
@@ -296,6 +304,13 @@ export default function PublicScheduledTaskPage() {
|
||||
if (run.status !== "succeeded") return false;
|
||||
const result = run.result;
|
||||
if (!result) return false;
|
||||
// HTML page runs always have something to render (the iframe preview).
|
||||
const isHtml =
|
||||
result.format === "html" ||
|
||||
(result.files ?? []).some(
|
||||
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
if (isHtml) return true;
|
||||
const hasBody = !!result.content && result.content.trim().length > 0;
|
||||
const hasMarkdownFile = (result.files ?? []).some(
|
||||
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
||||
@@ -550,6 +565,23 @@ type ContentTab = {
|
||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
||||
const isHtmlRun =
|
||||
run.result?.format === "html" ||
|
||||
(run.result?.files ?? []).some(
|
||||
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
// Prefer the inline copy; fall back to the LONGTEXT-backed /html endpoint for
|
||||
// large pages whose HTML was not inlined into result_json.
|
||||
const { data: htmlData } = useQuery({
|
||||
queryKey: ["public-scheduled-task", "html", run.id],
|
||||
queryFn: () => fetchPublicRunHtml(run.id),
|
||||
enabled: isHtmlRun && !run.result?.html,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
const htmlContent = run.result?.html ?? htmlData?.html ?? null;
|
||||
|
||||
// Skip files whose archive is missing on disk — otherwise the public viewer
|
||||
// would render a tab that 404s with "Result file is no longer available".
|
||||
const markdownFiles = useMemo(
|
||||
@@ -719,56 +751,74 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
下载原文件
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
{!isHtmlRun && (
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
{!isHtmlRun &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{tabs.length === 0 ? (
|
||||
{isHtmlRun ? (
|
||||
<div className="h-full p-4">
|
||||
{htmlContent ? (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={htmlContent}
|
||||
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tabs.length === 0 ? (
|
||||
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
|
||||
) : (
|
||||
<Tabs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Save, Code2, Trash2, X } from "lucide-react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,8 +26,11 @@ import {
|
||||
type ScheduledTaskRun,
|
||||
type ScheduledTaskRunMessage,
|
||||
updateScheduledTaskRunFile,
|
||||
useHtmlPageFavoriteActions,
|
||||
useHtmlPageFavorites,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
useScheduledTaskRunHtml,
|
||||
useScheduledTaskRunMessages,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
@@ -97,6 +100,22 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandboxed preview of a generated HTML document. The iframe has no
|
||||
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
|
||||
* of the server-side sanitization that already stripped script vectors.
|
||||
*/
|
||||
function HtmlPreviewFrame({ html }: { html: string }) {
|
||||
return (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={html}
|
||||
className="h-[70vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
|
||||
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
|
||||
@@ -217,6 +236,40 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
const canEditRun = run?.can_edit === true;
|
||||
const canDeleteRun = canEditRun;
|
||||
|
||||
// HTML page run: surface a sandboxed iframe preview + favorite button.
|
||||
const isHtmlRun = useMemo(() => {
|
||||
if (!result) return false;
|
||||
if (result.format === "html") return true;
|
||||
return (result.files ?? []).some(
|
||||
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
}, [result]);
|
||||
const { data: htmlData } = useScheduledTaskRunHtml(run_id, {
|
||||
enabled: isHtmlRun,
|
||||
live: runLive,
|
||||
});
|
||||
const htmlContent = htmlData?.html ?? result?.html ?? null;
|
||||
const htmlPageId = htmlData?.page_id ?? null;
|
||||
const favoriteActions = useHtmlPageFavoriteActions();
|
||||
const { data: htmlFavorites = [] } = useHtmlPageFavorites(isHtmlRun);
|
||||
const alreadyFavorited = !!htmlPageId && htmlFavorites.some((f) => f.page_id === htmlPageId);
|
||||
const handleFavorite = async () => {
|
||||
if (!htmlPageId) {
|
||||
toast.error("页面尚未生成或无法收藏");
|
||||
return;
|
||||
}
|
||||
if (alreadyFavorited) {
|
||||
toast.error("该页面已收藏,请勿重复收藏");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await favoriteActions.create.mutateAsync({ page_id: htmlPageId });
|
||||
toast.success("已收藏,可在新建 HTML 任务时作为参考风格");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "收藏失败");
|
||||
}
|
||||
};
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
@@ -444,11 +497,19 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
)}
|
||||
{run && (result || hasExecution) && (
|
||||
<Tabs
|
||||
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
|
||||
defaultValue={
|
||||
result ? (isHtmlRun ? "html" : fileCount > 0 ? "files" : "body") : "execution"
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
{result && isHtmlRun && (
|
||||
<TabsTrigger value="html" className="text-xs">
|
||||
<Code2 className="mr-1 size-3" />
|
||||
HTML 预览
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
@@ -476,6 +537,33 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{result && (
|
||||
<>
|
||||
{isHtmlRun && (
|
||||
<TabsContent value="html" className="m-0 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleFavorite}
|
||||
disabled={!htmlPageId || alreadyFavorited || favoriteActions.create.isPending}
|
||||
title={
|
||||
alreadyFavorited
|
||||
? "该页面已收藏"
|
||||
: "收藏此页面,作为后续 HTML 任务的参考风格"
|
||||
}
|
||||
>
|
||||
<Heart className={cn("size-4", alreadyFavorited && "fill-current text-rose-500")} />
|
||||
{alreadyFavorited ? "已收藏" : "收藏页面"}
|
||||
</Button>
|
||||
</div>
|
||||
{htmlContent ? (
|
||||
<HtmlPreviewFrame html={htmlContent} />
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
|
||||
@@ -45,10 +45,14 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
type PublishedScheduledTask,
|
||||
type ScheduledTask,
|
||||
type ScheduledTaskExecutionContext,
|
||||
useHtmlPageFavoriteActions,
|
||||
useHtmlPageFavorites,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskDeliveryProfile,
|
||||
useScheduledTaskRuns,
|
||||
@@ -110,6 +114,15 @@ const emptyForm = {
|
||||
prompt: "",
|
||||
execution_agent_name: "",
|
||||
require_markdown: false,
|
||||
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior.
|
||||
// Data gathering is delegated to the agent's own search skills — no explicit
|
||||
// search query / data source. ``html_model_name`` empty means "use the first
|
||||
// available model" — never a hardcoded GLM-5.
|
||||
task_kind: "agent" as "agent" | "html_page",
|
||||
html_page_prompt: "",
|
||||
html_negative_prompt: "",
|
||||
html_reference_favorite_id: "",
|
||||
html_model_name: "",
|
||||
};
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
@@ -140,6 +153,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);
|
||||
@@ -197,6 +229,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 ?? "",
|
||||
@@ -205,6 +238,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,
|
||||
@@ -223,18 +261,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);
|
||||
@@ -246,9 +314,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);
|
||||
@@ -404,6 +473,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}
|
||||
@@ -828,7 +905,7 @@ export default function ScheduledTasksPage() {
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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) =>
|
||||
@@ -844,6 +921,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} />
|
||||
@@ -855,55 +946,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
|
||||
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>
|
||||
|
||||
{!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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowLeft,
|
||||
CloudUpload,
|
||||
BookOpen,
|
||||
Files,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
|
||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||
import type { SourceItem } from "@/core/studio/types";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -36,6 +35,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ChatBox } from "@/components/workspace/chats/chat-box";
|
||||
import { rehypeCitations } from "@/core/rehype";
|
||||
|
||||
// ── 来源卡片图标 ────────────────────────────────────────
|
||||
@@ -101,24 +102,74 @@ function SourceCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
||||
|
||||
// ── 来源 Dialog ─────────────────────────────────────────
|
||||
function SourcesDialog({
|
||||
open,
|
||||
onClose,
|
||||
notebookName,
|
||||
notebookId,
|
||||
token,
|
||||
sources,
|
||||
isLoadingSources,
|
||||
selectedSourceIds,
|
||||
onToggle,
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
notebookName: string;
|
||||
notebookId: number;
|
||||
token: string;
|
||||
sources: SourceItem[];
|
||||
isLoadingSources: boolean;
|
||||
selectedSourceIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [panel, setPanel] = useState<"sources" | "text">("sources");
|
||||
const [textContent, setTextContent] = useState("");
|
||||
const [textTitle, setTextTitle] = useState("");
|
||||
const [submittingText, setSubmittingText] = useState(false);
|
||||
const [textError, setTextError] = useState("");
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
try {
|
||||
await uploadSourceFiles(notebookId, files, token);
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextSubmit = async () => {
|
||||
if (!textContent.trim()) return;
|
||||
setSubmittingText(true);
|
||||
setTextError("");
|
||||
try {
|
||||
await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token);
|
||||
setTextContent("");
|
||||
setTextTitle("");
|
||||
setPanel("sources");
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
setTextError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setSubmittingText(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent
|
||||
@@ -141,40 +192,89 @@ function SourcesDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* 左侧导航 */}
|
||||
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
||||
{[
|
||||
{ icon: Plus, label: "上传文件" },
|
||||
{ icon: CloudUpload, label: "从云端硬盘添加" },
|
||||
{ icon: Globe, label: "添加网站" },
|
||||
{ icon: AlignLeft, label: "复制的文字" },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="text-left leading-tight">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={UPLOAD_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => { void handleFileChange(e); }}
|
||||
/>
|
||||
<button
|
||||
disabled={uploading}
|
||||
onClick={() => { setPanel("sources"); fileInputRef.current?.click(); }}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
|
||||
<span className="text-left leading-tight">上传文件</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPanel("text"); setTextError(""); }}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-muted hover:text-foreground ${panel === "text" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<AlignLeft className="size-4 shrink-0" />
|
||||
<span className="text-left leading-tight">复制的文字</span>
|
||||
</button>
|
||||
{uploadError && (
|
||||
<p className="px-3 text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
{panel === "sources" ? (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||
<input
|
||||
autoFocus
|
||||
value={textTitle}
|
||||
onChange={(e) => setTextTitle(e.target.value)}
|
||||
placeholder="标题(可选)"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<textarea
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="在此粘贴或输入文字内容…"
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{textError && <p className="text-xs text-destructive">{textError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPanel("sources")}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
disabled={!textContent.trim() || submittingText}
|
||||
onClick={() => void handleTextSubmit()}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submittingText && <Loader2 className="size-3.5 animate-spin" />}
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -184,16 +284,19 @@ function SourcesDialog({
|
||||
// ── 主页面 ──────────────────────────────────────────────
|
||||
export default function StudioNotebookDetailPage() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
const { notebooks, isLoading } = useStudioNotebooks();
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
const queryClient = useQueryClient();
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
|
||||
const [initializedForNotebook, setInitializedForNotebook] = useState<number | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||
const THREADS_LIMIT = 5;
|
||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||
const [citationLoading, setCitationLoading] = useState(false);
|
||||
|
||||
@@ -206,6 +309,13 @@ export default function StudioNotebookDetailPage() {
|
||||
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
|
||||
|
||||
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && initializedForNotebook !== notebook?.id) {
|
||||
setInitializedForNotebook(notebook?.id ?? null);
|
||||
setSelectedSourceIds(sources.map((s) => s.id));
|
||||
}
|
||||
}, [sources, notebook?.id, initializedForNotebook]);
|
||||
const citationRehypePlugins = useMemo(() => {
|
||||
const mapped = sources.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -268,6 +378,8 @@ export default function StudioNotebookDetailPage() {
|
||||
const isChatView = activeThreadId !== null || hasMessages;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* 背景渐变(列表视图时显示) */}
|
||||
{!isChatView && (
|
||||
@@ -275,7 +387,7 @@ export default function StudioNotebookDetailPage() {
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
|
||||
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -299,75 +411,156 @@ export default function StudioNotebookDetailPage() {
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 列表视图:笔记本标题 + 历史线程列表 */}
|
||||
{!isChatView && (
|
||||
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-8 text-muted-foreground" />
|
||||
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{/* 右上角跳转按钮 */}
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
|
||||
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
|
||||
<div className="flex size-14 items-center justify-center leading-none select-none">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-10 text-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 输入框(列表视图内联) */}
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{(!notebookThreads || notebookThreads.length === 0) && (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
笔记本对话会显示在这里
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
|
||||
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notebookThreads.length > THREADS_LIMIT && (
|
||||
<button
|
||||
onClick={() => setThreadsExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
|
||||
{notebookThreads.map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted px-4 py-3 text-left transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,11 +592,11 @@ export default function StudioNotebookDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框区域 */}
|
||||
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
|
||||
{/* 聊天视图的底部输入框 */}
|
||||
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
|
||||
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -412,9 +605,17 @@ export default function StudioNotebookDetailPage() {
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -426,24 +627,6 @@ export default function StudioNotebookDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -484,11 +667,18 @@ export default function StudioNotebookDetailPage() {
|
||||
open={sourcesOpen}
|
||||
onClose={() => setSourcesOpen(false)}
|
||||
notebookName={notebook.name}
|
||||
notebookId={notebook.id}
|
||||
token={queryClient.getQueryData<string>(["studio", "token", getStoredAuth()?.email ?? ""]) ?? ""}
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
onUploadSuccess={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Files, Loader2, Plus } from "lucide-react";
|
||||
import { Files, Loader2, Plus, MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { createSearchSpace } from "@/core/studio/api";
|
||||
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import type { StudioNotebook } from "@/core/studio/types";
|
||||
import {
|
||||
@@ -15,81 +15,175 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function NotebookCardSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
|
||||
<div className="size-10 animate-pulse rounded-xl bg-muted" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
|
||||
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
|
||||
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-8">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookCard({ nb }: { nb: StudioNotebook }) {
|
||||
function NotebookCard({
|
||||
nb,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
|
||||
{nb.avatar_url ? (
|
||||
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
|
||||
) : (
|
||||
<Files className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="flex flex-1 flex-col p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
|
||||
{nb.avatar_url ? (
|
||||
<img src={nb.avatar_url} alt="" className="size-8 rounded object-contain" />
|
||||
) : (
|
||||
<Files className="size-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{/* spacer so title stays below icon */}
|
||||
<div className="size-7" />
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-1 pt-8">
|
||||
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* three-dot menu — absolute so it doesn't shift layout */}
|
||||
<div className="absolute right-4 top-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-full text-[#888] opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/10"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => onRename(nb)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(nb)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudioNotebooksPage() {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
// ── 新建 ──
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
// ── 重命名 ──
|
||||
const [renameTarget, setRenameTarget] = useState<StudioNotebook | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameError, setRenameError] = useState("");
|
||||
|
||||
// ── 删除 ──
|
||||
const [deleteTarget, setDeleteTarget] = useState<StudioNotebook | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
|
||||
function getToken() {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
return token;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) return;
|
||||
setCreating(true);
|
||||
setError("");
|
||||
setCreateError("");
|
||||
try {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
await createSearchSpace(trimmed, token);
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
// 刷新笔记本列表
|
||||
await createSearchSpace(trimmed, getToken());
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "创建失败");
|
||||
setCreateError(e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!renameTarget) return;
|
||||
const trimmed = renameName.trim();
|
||||
if (!trimmed) return;
|
||||
setRenaming(true);
|
||||
setRenameError("");
|
||||
try {
|
||||
await renameSearchSpace(renameTarget.id, trimmed, getToken());
|
||||
setRenameTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setRenameError(e instanceof Error ? e.message : "重命名失败");
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteSearchSpace(deleteTarget.id, getToken());
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-background p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||
<button
|
||||
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建笔记本
|
||||
@@ -100,13 +194,22 @@ export default function StudioNotebooksPage() {
|
||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
|
||||
{/* ── 新建 Dialog ── */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
@@ -114,25 +217,78 @@ export default function StudioNotebooksPage() {
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
|
||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button size="sm" disabled={!createName.trim() || creating} onClick={() => void handleCreate()}>
|
||||
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 重命名 Dialog ── */}
|
||||
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名笔记本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 flex-shrink-0 items-center justify-center text-3xl">
|
||||
{renameTarget?.avatar_url
|
||||
? <img src={renameTarget.avatar_url} alt="" className="size-10 rounded object-contain" />
|
||||
: <Files className="size-8 text-muted-foreground" />}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleRename(); }}
|
||||
disabled={renaming}
|
||||
className="flex-1 rounded-lg border-2 border-foreground bg-background px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{renameError && <p className="text-xs text-destructive">{renameError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={renaming} onClick={() => setRenameTarget(null)}>取消</Button>
|
||||
<Button size="sm" disabled={!renameName.trim() || renaming} onClick={() => void handleRename()}>
|
||||
{renaming ? <Loader2 className="size-4 animate-spin" /> : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 删除确认 Dialog ── */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除笔记本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||
</p>
|
||||
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={deleting} onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" size="sm" disabled={deleting} onClick={() => void handleDelete()}>
|
||||
{deleting ? <Loader2 className="size-4 animate-spin" /> : "删除"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
@@ -43,6 +43,11 @@ import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanni
|
||||
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;
|
||||
@@ -73,12 +78,7 @@ export function WorkspaceLayout({
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
: pageSidebarDark
|
||||
? // dark page sidebar:
|
||||
// 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px
|
||||
// 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边;
|
||||
// 左下角浮动按钮是 fixed,叠在主内容上不影响 layout
|
||||
// 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致
|
||||
pageSidebarCollapsed
|
||||
? pageSidebarCollapsed
|
||||
? '0rem'
|
||||
: '118px'
|
||||
: pageSidebarCollapsed
|
||||
@@ -87,15 +87,6 @@ export function WorkspaceLayout({
|
||||
|
||||
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',
|
||||
@@ -303,7 +294,7 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<StudioNotebookDetailPage />
|
||||
<NotebookDetailPageWithKey />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -470,3 +461,8 @@ export default function WorkspaceRoutes({
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Suppress unused import warnings for pages referenced only in some branches
|
||||
void UsersPage;
|
||||
void AdminUserActivityPage;
|
||||
void AdminMemoryPage;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ SERPER_API_KEY=your-serper-api-key
|
||||
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
|
||||
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
GATEWAY_CORS_ALLOW_ALL=0
|
||||
GATEWAY_CORS_ALLOW_ALL=1
|
||||
GATEWAY_ENABLE_DOCS=true
|
||||
GATEWAY_WORKERS=2
|
||||
DEER_FLOW_AUTH_DISABLED=false
|
||||
|
||||
@@ -245,7 +245,9 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||
|
||||
@@ -257,6 +259,10 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
|
||||
|
||||
**Unique task names**: task names are unique per user (`uq_scheduled_tasks_user_name`). `create_task`/`update_task`/`admin_update_task` proactively check via `_assert_name_available()` and raise `DuplicateTaskNameError` on a collision; the router translates that into HTTP `409` instead of letting the raw DB `IntegrityError` surface as a `500`.
|
||||
|
||||
**HTML page tasks (`task_kind == "html_page"`)**: a second task kind that renders a full HTML document each run instead of a Markdown report. The kind + its config (`html_config`: `page_prompt`, `negative_prompt`, `reference_favorite_id`, `model_name`) live inside `execution_context` — no schema change to `scheduled_tasks`. Data gathering is delegated to the agent's own search skills (driven by `page_prompt`), so there is no explicit search-query/data-source field; the render model is user-selectable (`model_name`, default `zai-org/GLM-5-FP8`). `_run_task_attempt()` dispatches by kind; `_run_html_page_attempt()` runs the pipeline: gather real data via the lead agent (its search tools) → optionally load a reference favorite's reusable style/layout → render with the chosen model (thinking off) → fault-tolerant JSON parse → sanitize (BeautifulSoup, strips `<script>`/`<iframe>`/`on*`/`javascript:`) → structural validate → one GLM5 repair pass if invalid → **completeness gate**: if the HTML is still not a full valid document (missing doctype/html/head/body, empty body, or residual scripts) after the repair pass, raise `HtmlGenerationError` so the run is marked **failed** (a page only counts as successful when it is a complete, previewable HTML document) → best-effort Playwright screenshot (no-op when Playwright absent) → archive `index.html` → persist a `scheduled_html_pages` row → return `result` with `format: "html"` and the full HTML inline. Pure helpers live in `deerflow/runtime/scheduler/html_page.py`. The frontend previews the HTML in a sandboxed `iframe` (no `allow-scripts`) on the run-detail and public pages; a user can bookmark a page (style summary extracted via GLM5) and later pick it as a style reference. New tables `scheduled_html_pages` + `html_page_favorites` (`deerflow/persistence/html_pages/`, wired as `app.state.html_page_store`) are created by `Base.metadata.create_all` with no migration. Tests: `tests/test_html_page_tasks.py`.
|
||||
|
||||
**HTML skills (tools the render step uses).** Two offline skills under `skills/public/` shape the generated HTML and are toggled in the skills settings page (enabled by default): `html-page-builder` (a self-contained inline-CSS design system — colors/cards/tables/KPI/responsive) and `html-charts` (inline-SVG charts that render in the no-`allow-scripts` preview iframe; optional local ECharts documented in its `assets/README.md`). A skill opts into HTML injection via `metadata.kind: html_page` in its SKILL.md frontmatter; `_collect_html_skill_instructions()` reads the bodies of **enabled** such skills and `build_generation_prompt(skill_instructions=…)` injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected `model_name` (no silent fallback to the default model — and there is **no hardcoded GLM-5**; an unset selection falls back to config `models[0]`). To stop auxiliary LLM calls from leaking onto the default model, **`TitleMiddleware` is skipped when `is_scheduled_run` is set** (`_build_middlewares` in `lead_agent/agent.py`) — scheduled/HTML runs use throwaway threads whose titles are never shown, and `config.title.model_name` is usually unset (→ would call `models[0]`, not the chosen model). The completeness gate (`validate_html`) also requires a non-empty `<title>` and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks.
|
||||
|
||||
### Custom Agent Persistence
|
||||
|
||||
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
|
||||
@@ -499,7 +505,7 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
|
||||
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
|
||||
- `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`):
|
||||
- `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=<shared-secret>`; empty `password` falls back to `123ewq`. Gate only — it does not change which account logs in.
|
||||
- `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with `Authorization: <service_authorization>` and the supplied token as the `access_token` cookie, reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract.
|
||||
- `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer <token>` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract.
|
||||
|
||||
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.gateway.routers import (
|
||||
auth,
|
||||
channels,
|
||||
feedback,
|
||||
html_page_favorites,
|
||||
intent,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
@@ -870,6 +871,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# surface results without a DeerFlow login.
|
||||
app.include_router(public_scheduled_tasks.router)
|
||||
|
||||
app.include_router(html_page_favorites.router)
|
||||
|
||||
# System settings API is mounted at /api/system-settings
|
||||
app.include_router(system_settings.router)
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.scheduled_task_store = make_scheduled_task_store(sf)
|
||||
|
||||
from deerflow.persistence.html_pages import make_html_page_store
|
||||
|
||||
app.state.html_page_store = make_html_page_store(sf)
|
||||
|
||||
from deerflow.persistence.llm_metrics import make_llm_metrics_store
|
||||
|
||||
app.state.llm_metrics_store = make_llm_metrics_store(sf)
|
||||
|
||||
@@ -255,11 +255,11 @@ async def _get_or_create_user_by_username(username: str):
|
||||
async def _fetch_username_from_token(token: str) -> str:
|
||||
"""Resolve an upstream access token to a username via the configured endpoint.
|
||||
|
||||
Calls ``auth_login.token_login.token_info_url`` exactly like the documented
|
||||
curl: a POST carrying ``Authorization: <service_authorization>`` plus the
|
||||
token as the ``access_token`` cookie. Expects ``{"state": "200", "data":
|
||||
{"username": ...}}`` and returns ``data.username``. Any upstream failure,
|
||||
non-200 ``state``, or missing username raises HTTP 401.
|
||||
Calls ``auth_login.token_login.token_info_url`` with the caller-supplied
|
||||
token placed directly in the ``Authorization: Bearer <token>`` header — the
|
||||
upstream reads the bearer token from there, not from a cookie. Expects
|
||||
``{"state": "200", "data": {"username": ...}}`` and returns ``data.username``.
|
||||
Any upstream failure, non-200 ``state``, or missing username raises HTTP 401.
|
||||
"""
|
||||
config = get_app_config().auth_login.token_login
|
||||
if not config.token_info_url:
|
||||
@@ -276,10 +276,9 @@ async def _fetch_username_from_token(token: str) -> str:
|
||||
upstream = await client.post(
|
||||
config.token_info_url,
|
||||
headers={
|
||||
"Authorization": config.service_authorization,
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
cookies={"access_token": token},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
# Surface the concrete failure (SSL handshake, DNS, timeout, ...) in logs
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""HTML page favorites API.
|
||||
|
||||
A favorite bookmarks a generated ``scheduled_html_pages`` row and stores a
|
||||
reusable style/layout summary (extracted via GLM5 at favorite time) so a later
|
||||
``html_page`` task can be generated "in the style of" the favorite.
|
||||
|
||||
All routes are owner-scoped; the underlying logic lives on
|
||||
``ScheduledTaskService`` so GLM5 access and the page store stay in one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user
|
||||
|
||||
# Importing the ORM models here (at app-module load, before the lifespan runs
|
||||
# ``Base.metadata.create_all``) registers ``scheduled_html_pages`` +
|
||||
# ``html_page_favorites`` with the metadata so the tables are auto-created on
|
||||
# startup. Without this the store's models are only imported later (inside
|
||||
# ``langgraph_runtime``), after create_all has already run — yielding
|
||||
# "no such table: html_page_favorites".
|
||||
from deerflow.persistence.html_pages.model import ( # noqa: F401
|
||||
HtmlPageFavoriteRow,
|
||||
ScheduledHtmlPageRow,
|
||||
)
|
||||
from deerflow.runtime.scheduler import get_scheduled_task_service
|
||||
from deerflow.runtime.scheduler.service import DuplicateFavoriteError
|
||||
|
||||
router = APIRouter(prefix="/api/html-page-favorites", tags=["html-page-favorites"])
|
||||
|
||||
|
||||
class HtmlPageFavoriteCreateRequest(BaseModel):
|
||||
page_id: str = Field(..., description="scheduled_html_pages.id to bookmark")
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
async def _user_id(request: Request) -> str:
|
||||
user_id = await get_current_user(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_html_page_favorites(request: Request) -> list[dict[str, Any]]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.list_html_page_favorites(user_id)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_html_page_favorite(body: HtmlPageFavoriteCreateRequest, request: Request) -> dict[str, Any]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
try:
|
||||
favorite = await service.create_html_page_favorite(
|
||||
user_id,
|
||||
body.page_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
)
|
||||
except DuplicateFavoriteError:
|
||||
raise HTTPException(status_code=409, detail="该页面已收藏,请勿重复收藏")
|
||||
if favorite is None:
|
||||
raise HTTPException(status_code=404, detail="页面不存在或无权收藏")
|
||||
return favorite
|
||||
|
||||
|
||||
@router.get("/{favorite_id}")
|
||||
async def get_html_page_favorite(favorite_id: str, request: Request) -> dict[str, Any]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
favorite = await service.get_html_page_favorite(user_id, favorite_id)
|
||||
if favorite is None:
|
||||
raise HTTPException(status_code=404, detail=f"Favorite {favorite_id} not found")
|
||||
return favorite
|
||||
|
||||
|
||||
@router.delete("/{favorite_id}")
|
||||
async def delete_html_page_favorite(favorite_id: str, request: Request) -> dict[str, Any]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.delete_html_page_favorite(user_id, favorite_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Favorite {favorite_id} not found")
|
||||
return {"success": True}
|
||||
@@ -243,6 +243,39 @@ async def public_get_run(run_id: str) -> dict[str, Any]:
|
||||
return _redact_run(run)
|
||||
|
||||
|
||||
def _html_payload(run_id: str, page: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"task_id": page.get("task_id"),
|
||||
"title": page.get("title"),
|
||||
"html": page.get("html_content"),
|
||||
"style_summary": page.get("style_summary"),
|
||||
"layout_summary": page.get("layout_summary"),
|
||||
"page_id": page.get("id"),
|
||||
"created_at": page.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/html")
|
||||
async def public_get_run_html(run_id: str) -> dict[str, Any]:
|
||||
"""Return the generated HTML document for an ``html_page`` run (anonymous)."""
|
||||
service = get_scheduled_task_service()
|
||||
page = await service.get_html_page_for_run(run_id)
|
||||
if page is None:
|
||||
raise HTTPException(status_code=404, detail="该执行记录没有 HTML 页面")
|
||||
return _html_payload(run_id, page)
|
||||
|
||||
|
||||
@router.get("/{task_id}/latest-html")
|
||||
async def public_get_latest_html(task_id: str) -> dict[str, Any]:
|
||||
"""Return the most recent generated HTML page for a task (anonymous)."""
|
||||
service = get_scheduled_task_service()
|
||||
page = await service.get_latest_html_page_for_task(task_id)
|
||||
if page is None:
|
||||
raise HTTPException(status_code=404, detail="该任务暂无 HTML 页面")
|
||||
return _html_payload(str(page.get("run_id") or ""), page)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/files/{file_index}")
|
||||
async def public_get_run_file(run_id: str, file_index: int) -> dict[str, Any]:
|
||||
"""Return a single result-file entry with its inline text content where
|
||||
|
||||
@@ -251,6 +251,29 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/html")
|
||||
async def get_scheduled_task_run_html(run_id: str, request: Request) -> dict:
|
||||
"""Return the generated HTML document for an ``html_page`` run."""
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await _load_run(service, request, run_id, user_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
page = await service.get_html_page_for_run(run_id)
|
||||
if page is None:
|
||||
raise HTTPException(status_code=404, detail="该执行记录没有 HTML 页面")
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"task_id": page.get("task_id") or run.get("task_id"),
|
||||
"title": page.get("title"),
|
||||
"html": page.get("html_content"),
|
||||
"style_summary": page.get("style_summary"),
|
||||
"layout_summary": page.get("layout_summary"),
|
||||
"page_id": page.get("id"),
|
||||
"created_at": page.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
def _event_type_for_checkpoint_message(message: dict) -> str:
|
||||
"""Map a serialized LangChain message to the same ``event_type`` strings
|
||||
the run-event store uses, so the frontend's existing role labels keep
|
||||
|
||||
@@ -30,7 +30,7 @@ from deerflow.skills import Skill
|
||||
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
||||
from deerflow.skills.security_scanner import scan_skill_content
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.storage import SkillStorage, get_or_new_skill_storage
|
||||
from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -210,6 +210,12 @@ class CustomSkillUpdateRequest(BaseModel):
|
||||
content: str = Field(..., description="Replacement SKILL.md content")
|
||||
|
||||
|
||||
class SkillDuplicateRequest(BaseModel):
|
||||
"""Request model for duplicating an existing skill into a new custom skill."""
|
||||
|
||||
new_name: str = Field(..., description="Name for the new custom skill copy (lowercase letters/digits, '-'/'_' separators)")
|
||||
|
||||
|
||||
class CustomSkillHistoryResponse(BaseModel):
|
||||
history: list[dict]
|
||||
|
||||
@@ -950,6 +956,121 @@ async def install_skill(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
||||
|
||||
|
||||
def _rewrite_skill_frontmatter_name(content: str, new_name: str) -> str:
|
||||
"""Return *content* with the YAML-frontmatter ``name:`` field set to *new_name*.
|
||||
|
||||
A duplicated skill must declare the new name in its SKILL.md frontmatter or
|
||||
``validate_skill_markdown_content`` will reject it (frontmatter name must
|
||||
match the directory name). We rewrite only the first ``name:`` line inside
|
||||
the leading ``---`` block, leaving every other field byte-for-byte intact.
|
||||
"""
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("SKILL.md is missing the leading YAML frontmatter block")
|
||||
fm = match.group(1)
|
||||
new_fm, replaced = re.subn(r"(?m)^(name:\s*).*$", rf"\g<1>{new_name}", fm, count=1)
|
||||
if replaced == 0:
|
||||
new_fm = f"name: {new_name}\n{fm}"
|
||||
return content[: match.start(1)] + new_fm + content[match.end(1) :]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/custom/{source_name}/duplicate",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Duplicate Skill",
|
||||
description=(
|
||||
"Copy an existing skill (built-in/public OR custom) into a brand-new custom "
|
||||
"skill named ``new_name``, owned by the caller and unpublished. All support "
|
||||
"files are copied verbatim; only the SKILL.md frontmatter name is rewritten. "
|
||||
"The source already passed the install-time security scan, so no re-scan is run."
|
||||
),
|
||||
)
|
||||
async def duplicate_skill(
|
||||
source_name: str,
|
||||
body: SkillDuplicateRequest,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillInstallResponse:
|
||||
import shutil
|
||||
|
||||
try:
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
|
||||
source_name = SkillStorage.validate_skill_name(source_name.replace("\r\n", "").replace("\n", ""))
|
||||
new_name = SkillStorage.validate_skill_name(body.new_name.replace("\r\n", "").replace("\n", ""))
|
||||
|
||||
# Resolve the source directory (custom takes precedence over public, so a
|
||||
# user-shadowed copy is the one duplicated when both exist).
|
||||
if storage.custom_skill_exists(source_name):
|
||||
source_dir = storage.get_custom_skill_dir(source_name)
|
||||
elif storage.public_skill_exists(source_name):
|
||||
source_dir = storage.get_skills_root_path() / SkillCategory.PUBLIC.value / source_name
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{source_name}' not found")
|
||||
|
||||
if new_name == source_name or storage.custom_skill_exists(new_name) or storage.public_skill_exists(new_name):
|
||||
raise HTTPException(status_code=409, detail=f"A skill named '{new_name}' already exists")
|
||||
if not is_admin:
|
||||
existing = await skill_store.get_any(new_name)
|
||||
if existing and existing.get("owner_user_id") not in (None, user_id):
|
||||
raise HTTPException(status_code=409, detail=f"Skill '{new_name}' already exists and is owned by another user")
|
||||
|
||||
dest_dir = storage.get_custom_skill_dir(new_name)
|
||||
# copytree refuses to overwrite, which is the behaviour we want — the
|
||||
# existence checks above already guarantee a clean target.
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
try:
|
||||
source_content = (dest_dir / SKILL_MD_FILE).read_text(encoding="utf-8")
|
||||
new_content = _rewrite_skill_frontmatter_name(source_content, new_name)
|
||||
SkillStorage.validate_skill_markdown_content(new_name, new_content)
|
||||
storage.write_custom_skill(new_name, SKILL_MD_FILE, new_content)
|
||||
except Exception:
|
||||
# Roll back the half-created directory so a failed copy never leaves
|
||||
# an unparseable skill on disk.
|
||||
shutil.rmtree(dest_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
await skill_store.ensure_legacy({"name": new_name, "owner_user_id": user_id, "published": False})
|
||||
try:
|
||||
storage.append_history(
|
||||
new_name,
|
||||
{
|
||||
"action": "duplicate",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": SKILL_MD_FILE,
|
||||
"prev_content": None,
|
||||
"new_content": new_content,
|
||||
"source_skill": source_name,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from deerflow.skills.usage import mark_uploaded
|
||||
mark_uploaded(new_name)
|
||||
except Exception:
|
||||
pass
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillInstallResponse(
|
||||
success=True,
|
||||
skill_name=new_name,
|
||||
message=f"Skill '{source_name}' duplicated as '{new_name}'",
|
||||
overwritten=False,
|
||||
category=SkillCategory.CUSTOM.value,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to duplicate skill %s: %s", source_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to duplicate skill: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/reconcile-custom",
|
||||
response_model=SkillReconcileResponse,
|
||||
|
||||
@@ -195,11 +195,12 @@ auth_login:
|
||||
require_password: false
|
||||
password: ""
|
||||
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
|
||||
# (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在
|
||||
# 库里时自动注册。
|
||||
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
|
||||
# 用户名不在库里时自动注册。
|
||||
token_login:
|
||||
enabled: false
|
||||
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
|
||||
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
|
||||
service_authorization: "Bearer admin"
|
||||
timeout_seconds: 10
|
||||
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
|
||||
@@ -215,7 +216,7 @@ database:
|
||||
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: D:\05271502\deerflow-server\offline-backend-20260512\backend\.deer-flow\data\deerflow.db
|
||||
connection_string: .deer-flow/data/deerflow.db
|
||||
|
||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
|
||||
@@ -90,14 +90,13 @@ Content-Type: application/json
|
||||
POST /api/v1/auth/login/token?token=<上游access_token>
|
||||
```
|
||||
|
||||
后端据此向 `token_info_url` 发起(与下方 curl 对齐):
|
||||
后端据此向 `token_info_url` 发起(与下方 curl 对齐):**前端传入的 token 直接拼成 `Authorization: Bearer <token>` 头**,上游从该头读取令牌(不再用 cookie,也不再用静态 `service_authorization`)。
|
||||
|
||||
```bash
|
||||
curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \
|
||||
-X POST \
|
||||
-H 'authorization: Bearer admin' \
|
||||
-H 'content-type: application/x-www-form-urlencoded' \
|
||||
-H 'cookie: access_token=<上游access_token>'
|
||||
-H 'authorization: Bearer <上游access_token>' \
|
||||
-H 'content-type: application/x-www-form-urlencoded'
|
||||
```
|
||||
|
||||
上游返回示例:
|
||||
@@ -131,7 +130,7 @@ curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \
|
||||
|
||||
- 两个端点都在 `auth_middleware._PUBLIC_EXACT_PATHS` 白名单内(免会话鉴权,自身完成鉴权)。
|
||||
- 口令门是**最低限度**的共享密钥防护,适合内网/受控环境;非每用户独立密码。
|
||||
- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `service_authorization` 与 `token_info_url` 配置正确且走内网/HTTPS。
|
||||
- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `token_info_url` 配置正确且走内网/HTTPS。前端传入的 token 会直接作为 `Authorization: Bearer <token>` 发给上游,因此该 token 必须来自可信入口。(`service_authorization` 配置项已不再参与请求,保留仅为兼容,后续可移除。)
|
||||
|
||||
## 相关代码
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"code-retrieval": { "enabled": true },
|
||||
"news-briefing": { "enabled": true },
|
||||
"workspace-search": { "enabled": true },
|
||||
"policy-knowledge": { "enabled": true }
|
||||
"policy-knowledge": { "enabled": true },
|
||||
"html-page-builder": { "enabled": true },
|
||||
"html-charts": { "enabled": true }
|
||||
}
|
||||
}
|
||||
+91
-2
@@ -1,8 +1,9 @@
|
||||
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
|
||||
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -21,6 +22,10 @@ from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
|
||||
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
|
||||
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
|
||||
|
||||
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
|
||||
_DEFAULT_MAX_MATERIALS = 50
|
||||
_MIN_MAX_MATERIALS = 1
|
||||
@@ -95,6 +100,67 @@ def _notice(title: str, detail: str, raw: str = "") -> dict:
|
||||
return event
|
||||
|
||||
|
||||
async def _search_notebook(
|
||||
keywords: list[str],
|
||||
search_space_id: int,
|
||||
source_ids: list[int],
|
||||
token: str,
|
||||
) -> list[Material]:
|
||||
"""从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。"""
|
||||
import httpx # 懒导入:仅在笔记模式下才需要
|
||||
|
||||
base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||
if not base:
|
||||
logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search")
|
||||
return []
|
||||
|
||||
results: list[Material] = []
|
||||
mat_idx = 0
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{base}{_NOTEBOOK_SEARCH_PATH}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": kw,
|
||||
"source_ids": source_ids,
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": _NOTEBOOK_TOP_K,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# 解析 documents 列表,每个 chunk 作为独立素材
|
||||
documents: list[dict] = data.get("documents") if isinstance(data, dict) else []
|
||||
for doc in documents or []:
|
||||
doc_title: str = doc.get("title") or "笔记文档"
|
||||
doc_url: str = doc.get("url") or ""
|
||||
for chunk in doc.get("chunks") or []:
|
||||
chunk_content: str = (chunk.get("content") or "").strip()
|
||||
if not chunk_content:
|
||||
continue
|
||||
mat_idx += 1
|
||||
raw_score = chunk.get("score")
|
||||
chunk_score = float(raw_score) if raw_score is not None else max(0.5, 0.95 - (mat_idx - 1) * 0.03)
|
||||
results.append(Material(
|
||||
id=f"nb{mat_idx:03d}",
|
||||
title=doc_title,
|
||||
content=chunk_content[:500],
|
||||
source=f"笔记·{doc_title}",
|
||||
source_type="notebook",
|
||||
relevance_score=chunk_score,
|
||||
url=doc_url,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("notebook search kw=%r failed: %s", kw, e)
|
||||
except Exception as e:
|
||||
logger.warning("notebook search client error: %s", e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
intent = state["user_intent"]
|
||||
article_type = state.get("article_type", "analysis")
|
||||
@@ -181,6 +247,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
if extra_keywords:
|
||||
keywords = list(dict.fromkeys(keywords + extra_keywords))
|
||||
|
||||
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
|
||||
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
|
||||
notebook_materials: list[Material] = []
|
||||
if notebook_search_space_id:
|
||||
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
|
||||
notebook_token: str = cfg.get("notebook_token") or ""
|
||||
_emit("正在检索笔记内容…")
|
||||
notebook_materials = await _search_notebook(
|
||||
keywords[:_NOTEBOOK_MAX_KEYWORDS],
|
||||
notebook_search_space_id,
|
||||
notebook_source_ids,
|
||||
notebook_token,
|
||||
)
|
||||
if notebook_materials:
|
||||
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
|
||||
|
||||
# Step 2: 搜索
|
||||
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
|
||||
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
|
||||
@@ -330,10 +412,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
existing_pkg = state.get("material_package")
|
||||
if is_skill_search:
|
||||
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
|
||||
# 笔记素材追加在技能素材之后
|
||||
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
|
||||
raw_materials.extend(notebook_materials)
|
||||
else:
|
||||
seen_urls: set[str] = set()
|
||||
raw_materials = []
|
||||
# 笔记素材优先排在最前面,赋予高相关度
|
||||
raw_materials = list(notebook_materials)
|
||||
for m in notebook_materials:
|
||||
if m.get("url"):
|
||||
seen_urls.add(m["url"])
|
||||
|
||||
preserve_existing = existing_pkg and (is_supplement or is_append_only)
|
||||
if preserve_existing:
|
||||
for m in existing_pkg.get("materials", []):
|
||||
|
||||
@@ -140,6 +140,9 @@ class AIWritingState(TypedDict):
|
||||
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
|
||||
# model_name,存进 state 才能让 resume 后才运行的节点拿到用户模型。
|
||||
model_name: str
|
||||
# 笔记来源(可选):传入后 researcher 阶段会额外从笔记检索素材
|
||||
notebook_search_space_id: Optional[int]
|
||||
notebook_source_ids: Optional[List[int]]
|
||||
|
||||
# Agent A 输出
|
||||
material_package: Optional[MaterialPackage]
|
||||
|
||||
+8
-2
@@ -280,8 +280,14 @@ def _build_middlewares(
|
||||
if resolved_app_config.token_usage.enabled:
|
||||
middlewares.append(TokenUsageMiddleware())
|
||||
|
||||
# Add TitleMiddleware
|
||||
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
|
||||
# Add TitleMiddleware — but skip it for scheduled runs. Each scheduled run
|
||||
# executes on a throwaway thread whose title is never shown to a user, and
|
||||
# generating one would fire an extra LLM call on the *default* model
|
||||
# (config.title.model_name is usually unset → models[0]), which is not the
|
||||
# model the task selected. Skipping it keeps scheduled/HTML runs on exactly
|
||||
# the chosen model.
|
||||
if not cfg.get("is_scheduled_run", False):
|
||||
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
|
||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||
|
||||
@@ -33,8 +33,8 @@ class TokenLoginConfig(BaseModel):
|
||||
"""Token-exchange login.
|
||||
|
||||
The frontend passes an upstream access token via the URL. The gateway calls
|
||||
``token_info_url`` with ``Authorization: <service_authorization>`` and the
|
||||
token as the ``access_token`` cookie, reads ``data.username`` from the
|
||||
``token_info_url`` with that token placed directly in the
|
||||
``Authorization: Bearer <token>`` header, reads ``data.username`` from the
|
||||
response, then signs the matching local user in (auto-registering if the
|
||||
username is not yet in the database).
|
||||
"""
|
||||
@@ -49,7 +49,10 @@ class TokenLoginConfig(BaseModel):
|
||||
)
|
||||
service_authorization: str = Field(
|
||||
default="Bearer admin",
|
||||
description="Authorization header value used when calling token_info_url.",
|
||||
description=(
|
||||
"Deprecated/unused: the Authorization header is now built from the "
|
||||
"caller's token as 'Bearer <token>'. Retained for backward compat."
|
||||
),
|
||||
)
|
||||
timeout_seconds: float = Field(
|
||||
default=10.0,
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"""Generated HTML page + favorite persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.html_pages.base import HtmlPageStore
|
||||
from deerflow.persistence.html_pages.memory import MemoryHtmlPageStore
|
||||
from deerflow.persistence.html_pages.model import HtmlPageFavoriteRow, ScheduledHtmlPageRow
|
||||
from deerflow.persistence.html_pages.sql import HtmlPageRepository
|
||||
|
||||
__all__ = [
|
||||
"HtmlPageStore",
|
||||
"HtmlPageRepository",
|
||||
"MemoryHtmlPageStore",
|
||||
"HtmlPageFavoriteRow",
|
||||
"ScheduledHtmlPageRow",
|
||||
"make_html_page_store",
|
||||
]
|
||||
|
||||
|
||||
def make_html_page_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryHtmlPageStore()
|
||||
return HtmlPageRepository(session_factory)
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""Abstract persistence API for generated HTML pages and favorites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HtmlPageStore(ABC):
|
||||
# --- Generated pages ---
|
||||
|
||||
@abstractmethod
|
||||
async def create_page(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_page(self, page_id: str) -> dict[str, Any] | None:
|
||||
"""Return a page by id without restricting by owner."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_page_by_run(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""Return the page produced by a given run, if any."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def latest_page_for_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Return the most recently created page for a task, if any."""
|
||||
raise NotImplementedError
|
||||
|
||||
# --- Favorites ---
|
||||
|
||||
@abstractmethod
|
||||
async def create_favorite(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_favorites(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_favorite(self, favorite_id: str) -> dict[str, Any] | None:
|
||||
"""Return a favorite by id without restricting by owner."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_favorite_by_page(self, user_id: str, page_id: str) -> dict[str, Any] | None:
|
||||
"""Return the user's existing favorite for a page, if any (dedup check)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_favorite(self, favorite_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""In-memory HtmlPageStore for no-database / test deployments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.html_pages.base import HtmlPageStore
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class MemoryHtmlPageStore(HtmlPageStore):
|
||||
def __init__(self) -> None:
|
||||
self._pages: dict[str, dict[str, Any]] = {}
|
||||
self._favorites: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# --- Generated pages ---
|
||||
|
||||
async def create_page(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = {
|
||||
"id": data["id"],
|
||||
"task_id": data["task_id"],
|
||||
"run_id": data["run_id"],
|
||||
"user_id": data["user_id"],
|
||||
"title": data.get("title") or "",
|
||||
"html_content": data.get("html_content") or "",
|
||||
"html_file_path": data.get("html_file_path"),
|
||||
"data_snapshot": data.get("data_snapshot") or {},
|
||||
"prompt_snapshot": data.get("prompt_snapshot") or {},
|
||||
"reference_favorite_id": data.get("reference_favorite_id"),
|
||||
"style_summary": data.get("style_summary"),
|
||||
"layout_summary": data.get("layout_summary"),
|
||||
"created_at": _now_iso(),
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
self._pages[row["id"]] = row
|
||||
return copy.deepcopy(row)
|
||||
|
||||
async def get_page(self, page_id: str) -> dict[str, Any] | None:
|
||||
row = self._pages.get(page_id)
|
||||
return copy.deepcopy(row) if row is not None else None
|
||||
|
||||
async def get_page_by_run(self, run_id: str) -> dict[str, Any] | None:
|
||||
matches = [r for r in self._pages.values() if r["run_id"] == run_id]
|
||||
if not matches:
|
||||
return None
|
||||
matches.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return copy.deepcopy(matches[0])
|
||||
|
||||
async def latest_page_for_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
matches = [r for r in self._pages.values() if r["task_id"] == task_id]
|
||||
if not matches:
|
||||
return None
|
||||
matches.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return copy.deepcopy(matches[0])
|
||||
|
||||
# --- Favorites ---
|
||||
|
||||
async def create_favorite(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = {
|
||||
"id": data["id"],
|
||||
"user_id": data["user_id"],
|
||||
"page_id": data["page_id"],
|
||||
"title": data.get("title") or "",
|
||||
"description": data.get("description"),
|
||||
"tags": data.get("tags") or [],
|
||||
"html_snapshot": data.get("html_snapshot") or "",
|
||||
"style_summary": data.get("style_summary"),
|
||||
"layout_summary": data.get("layout_summary"),
|
||||
"content_structure_summary": data.get("content_structure_summary"),
|
||||
"reuse_prompt": data.get("reuse_prompt"),
|
||||
"created_at": _now_iso(),
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
self._favorites[row["id"]] = row
|
||||
return copy.deepcopy(row)
|
||||
|
||||
async def list_favorites(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
matches = [r for r in self._favorites.values() if r["user_id"] == user_id]
|
||||
matches.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return [copy.deepcopy(r) for r in matches[: max(1, limit)]]
|
||||
|
||||
async def get_favorite(self, favorite_id: str) -> dict[str, Any] | None:
|
||||
row = self._favorites.get(favorite_id)
|
||||
return copy.deepcopy(row) if row is not None else None
|
||||
|
||||
async def get_favorite_by_page(self, user_id: str, page_id: str) -> dict[str, Any] | None:
|
||||
for row in self._favorites.values():
|
||||
if row["user_id"] == user_id and row["page_id"] == page_id:
|
||||
return copy.deepcopy(row)
|
||||
return None
|
||||
|
||||
async def delete_favorite(self, favorite_id: str, user_id: str) -> bool:
|
||||
row = self._favorites.get(favorite_id)
|
||||
if row is None or row["user_id"] != user_id:
|
||||
return False
|
||||
del self._favorites[favorite_id]
|
||||
return True
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""ORM models for generated HTML pages and user favorites.
|
||||
|
||||
These back the "HTML 页面定时任务" feature: a scheduled task of kind
|
||||
``html_page`` renders a full HTML document each run. Every rendered page is
|
||||
recorded in :class:`ScheduledHtmlPageRow`; a user may bookmark one into
|
||||
:class:`HtmlPageFavoriteRow`, which also stores a reusable style/layout
|
||||
summary so a later task can be generated "in the style of" the favorite.
|
||||
|
||||
Both tables are brand new, so ``Base.metadata.create_all`` creates them on the
|
||||
next startup without touching any existing schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
|
||||
|
||||
|
||||
class ScheduledHtmlPageRow(Base):
|
||||
"""One HTML page produced by a single run of an ``html_page`` task."""
|
||||
|
||||
__tablename__ = "scheduled_html_pages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
run_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
# Full HTML document — can be large, so LONGTEXT on MySQL.
|
||||
html_content: Mapped[str] = mapped_column(PortableLongText())
|
||||
html_file_path: Mapped[str | None] = mapped_column(Text)
|
||||
data_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict)
|
||||
prompt_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict)
|
||||
reference_favorite_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
style_summary: Mapped[str | None] = mapped_column(Text)
|
||||
layout_summary: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class HtmlPageFavoriteRow(Base):
|
||||
"""A user-bookmarked HTML page, with a reusable style/layout summary."""
|
||||
|
||||
__tablename__ = "html_page_favorites"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
page_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
tags_json: Mapped[list | None] = mapped_column(PortableJSON(), default=list)
|
||||
# Snapshot of the page HTML at favorite time, decoupled from the source run
|
||||
# so deleting the run never breaks the favorite reference.
|
||||
html_snapshot: Mapped[str] = mapped_column(PortableLongText())
|
||||
style_summary: Mapped[str | None] = mapped_column(Text)
|
||||
layout_summary: Mapped[str | None] = mapped_column(Text)
|
||||
content_structure_summary: Mapped[str | None] = mapped_column(Text)
|
||||
reuse_prompt: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""SQLAlchemy repository for generated HTML pages and favorites."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.html_pages.base import HtmlPageStore
|
||||
from deerflow.persistence.html_pages.model import HtmlPageFavoriteRow, ScheduledHtmlPageRow
|
||||
|
||||
|
||||
def _to_dict(row) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
# Normalize the JSON column names to API-friendly keys.
|
||||
if "data_snapshot_json" in data:
|
||||
data["data_snapshot"] = data.pop("data_snapshot_json") or {}
|
||||
if "prompt_snapshot_json" in data:
|
||||
data["prompt_snapshot"] = data.pop("prompt_snapshot_json") or {}
|
||||
if "tags_json" in data:
|
||||
data["tags"] = data.pop("tags_json") or []
|
||||
return data
|
||||
|
||||
|
||||
class HtmlPageRepository(HtmlPageStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
# --- Generated pages ---
|
||||
|
||||
async def create_page(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = ScheduledHtmlPageRow(
|
||||
id=data["id"],
|
||||
task_id=data["task_id"],
|
||||
run_id=data["run_id"],
|
||||
user_id=data["user_id"],
|
||||
title=data.get("title") or "",
|
||||
html_content=data.get("html_content") or "",
|
||||
html_file_path=data.get("html_file_path"),
|
||||
data_snapshot_json=data.get("data_snapshot") or {},
|
||||
prompt_snapshot_json=data.get("prompt_snapshot") or {},
|
||||
reference_favorite_id=data.get("reference_favorite_id"),
|
||||
style_summary=data.get("style_summary"),
|
||||
layout_summary=data.get("layout_summary"),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def get_page(self, page_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledHtmlPageRow, page_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def get_page_by_run(self, run_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledHtmlPageRow)
|
||||
.where(ScheduledHtmlPageRow.run_id == run_id)
|
||||
.order_by(ScheduledHtmlPageRow.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def latest_page_for_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledHtmlPageRow)
|
||||
.where(ScheduledHtmlPageRow.task_id == task_id)
|
||||
.order_by(ScheduledHtmlPageRow.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
# --- Favorites ---
|
||||
|
||||
async def create_favorite(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = HtmlPageFavoriteRow(
|
||||
id=data["id"],
|
||||
user_id=data["user_id"],
|
||||
page_id=data["page_id"],
|
||||
title=data.get("title") or "",
|
||||
description=data.get("description"),
|
||||
tags_json=data.get("tags") or [],
|
||||
html_snapshot=data.get("html_snapshot") or "",
|
||||
style_summary=data.get("style_summary"),
|
||||
layout_summary=data.get("layout_summary"),
|
||||
content_structure_summary=data.get("content_structure_summary"),
|
||||
reuse_prompt=data.get("reuse_prompt"),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def list_favorites(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(HtmlPageFavoriteRow)
|
||||
.where(HtmlPageFavoriteRow.user_id == user_id)
|
||||
.order_by(HtmlPageFavoriteRow.created_at.desc())
|
||||
.limit(max(1, limit))
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_favorite(self, favorite_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(HtmlPageFavoriteRow, favorite_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def get_favorite_by_page(self, user_id: str, page_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(HtmlPageFavoriteRow)
|
||||
.where(HtmlPageFavoriteRow.user_id == user_id, HtmlPageFavoriteRow.page_id == page_id)
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def delete_favorite(self, favorite_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(HtmlPageFavoriteRow).where(
|
||||
HtmlPageFavoriteRow.id == favorite_id,
|
||||
HtmlPageFavoriteRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return (result.rowcount or 0) > 0
|
||||
+9
-1
@@ -1,8 +1,16 @@
|
||||
from .registry import get_scheduled_task_service, set_scheduled_task_service
|
||||
from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
|
||||
from .service import (
|
||||
DuplicateFavoriteError,
|
||||
DuplicateTaskNameError,
|
||||
HtmlGenerationError,
|
||||
MarkdownRequirementError,
|
||||
ScheduledTaskService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DuplicateFavoriteError",
|
||||
"DuplicateTaskNameError",
|
||||
"HtmlGenerationError",
|
||||
"MarkdownRequirementError",
|
||||
"ScheduledTaskService",
|
||||
"get_scheduled_task_service",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ class ElementDeliveryClient:
|
||||
self.access_token = access_token
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ElementDeliveryClient | None":
|
||||
def from_env(cls) -> ElementDeliveryClient | None:
|
||||
enabled = os.environ.get("ELEMENT_ENABLED", "true").strip().lower()
|
||||
if enabled in {"0", "false", "no", "off"}:
|
||||
return None
|
||||
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
"""HTML page generation helpers for ``html_page`` scheduled tasks.
|
||||
|
||||
Pure, dependency-light utilities the scheduler uses to turn a GLM5 response
|
||||
into a safe, previewable HTML document:
|
||||
|
||||
- :func:`extract_json_object` — fault-tolerant JSON extraction from a model
|
||||
reply that may be wrapped in prose or ```json fences.
|
||||
- :func:`sanitize_html` — strip script-execution vectors (``<script>``,
|
||||
``<iframe>``, ``on*`` handlers, ``javascript:`` URLs, …) using BeautifulSoup.
|
||||
- :func:`validate_html` — cheap structural checks (doctype/html/head/body,
|
||||
non-empty body, no residual scripts) that gate the optional repair pass.
|
||||
- ``build_*_prompt`` — the GLM5 prompt templates (generation / repair / style
|
||||
extraction).
|
||||
- :func:`screenshot_html` — best-effort Playwright screenshot, a no-op when
|
||||
Playwright is not installed (the offline image ships without a browser).
|
||||
|
||||
Kept free of any ``app.*`` import so it stays inside the harness boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tags removed wholesale — they either execute code or load remote active
|
||||
# content that the iframe sandbox alone shouldn't be relied on to contain.
|
||||
_DANGEROUS_TAGS = frozenset(
|
||||
{"script", "iframe", "object", "embed", "applet", "frame", "frameset", "base"}
|
||||
)
|
||||
|
||||
# URL schemes that can execute script when navigated/loaded.
|
||||
_DANGEROUS_URL_RE = re.compile(r"^\s*(javascript|vbscript|data:text/html)", re.IGNORECASE)
|
||||
|
||||
# Attributes carrying a URL we must scheme-check.
|
||||
_URL_ATTRS = frozenset({"href", "src", "action", "formaction", "xlink:href", "background", "poster"})
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split a ``---`` YAML frontmatter block from the body of a SKILL.md.
|
||||
|
||||
Returns ``(frontmatter_dict, body)``. When there is no frontmatter the dict
|
||||
is empty and the body is the whole text.
|
||||
"""
|
||||
if not text or not text.lstrip().startswith("---"):
|
||||
return {}, text or ""
|
||||
lines = text.splitlines()
|
||||
# Skip leading blank lines before the opening fence.
|
||||
start = 0
|
||||
while start < len(lines) and not lines[start].strip():
|
||||
start += 1
|
||||
end = None
|
||||
for i in range(start + 1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end = i
|
||||
break
|
||||
if end is None:
|
||||
return {}, text
|
||||
fm_block = "\n".join(lines[start + 1 : end])
|
||||
body = "\n".join(lines[end + 1 :])
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(fm_block) or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return data, body
|
||||
|
||||
|
||||
def extract_json_object(text: str | None) -> dict[str, Any] | None:
|
||||
"""Best-effort parse of a JSON object from a model reply.
|
||||
|
||||
Handles three common shapes: a bare JSON object, a ```json fenced block,
|
||||
and a JSON object embedded in surrounding prose. Returns ``None`` when no
|
||||
balanced object can be recovered.
|
||||
"""
|
||||
if not text or not isinstance(text, str):
|
||||
return None
|
||||
candidate = text.strip()
|
||||
|
||||
# Strip a single ```json ... ``` (or ``` ... ```) fence if present.
|
||||
fence = re.search(r"```(?:json)?\s*(.*?)```", candidate, flags=re.DOTALL | re.IGNORECASE)
|
||||
if fence:
|
||||
candidate = fence.group(1).strip()
|
||||
|
||||
# Fast path: the whole thing is already valid JSON.
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback: scan for the first balanced ``{ ... }`` and parse that.
|
||||
start = candidate.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for index in range(start, len(candidate)):
|
||||
char = candidate[index]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif char == "\\":
|
||||
escape = True
|
||||
elif char == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = True
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
blob = candidate[start : index + 1]
|
||||
try:
|
||||
parsed = json.loads(blob)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_html(html: str) -> tuple[str, list[str]]:
|
||||
"""Remove script-execution vectors from ``html``.
|
||||
|
||||
Returns ``(cleaned_html, removed)`` where ``removed`` is a human-readable
|
||||
report of what was stripped (used for logging / repair feedback). The
|
||||
cleaner is defensive but not a full HTML validator — pair it with an
|
||||
``iframe sandbox`` (no ``allow-scripts``) on the preview side.
|
||||
"""
|
||||
if not html or not isinstance(html, str):
|
||||
return "", []
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except Exception: # pragma: no cover - bs4 is a declared dependency
|
||||
logger.warning("BeautifulSoup unavailable; returning HTML unsanitized")
|
||||
return html, []
|
||||
|
||||
removed: list[str] = []
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# 1) Drop dangerous tags entirely.
|
||||
for tag in soup.find_all(True):
|
||||
name = (tag.name or "").lower()
|
||||
if name in _DANGEROUS_TAGS:
|
||||
removed.append(f"<{name}>")
|
||||
tag.decompose()
|
||||
continue
|
||||
# External stylesheet / preload links pull remote resources.
|
||||
if name == "link":
|
||||
href = str(tag.get("href") or "")
|
||||
rel = " ".join(tag.get("rel") or []) if isinstance(tag.get("rel"), list) else str(tag.get("rel") or "")
|
||||
if re.match(r"^\s*(https?:)?//", href) and "stylesheet" in rel.lower():
|
||||
removed.append("<link stylesheet>")
|
||||
tag.decompose()
|
||||
continue
|
||||
# meta refresh can redirect to arbitrary URLs.
|
||||
if name == "meta" and str(tag.get("http-equiv") or "").lower() == "refresh":
|
||||
removed.append("<meta refresh>")
|
||||
tag.decompose()
|
||||
continue
|
||||
|
||||
# 2) Strip event handlers and dangerous URL schemes from surviving tags.
|
||||
for tag in soup.find_all(True):
|
||||
for attr in list(tag.attrs.keys()):
|
||||
lowered = attr.lower()
|
||||
if lowered.startswith("on"):
|
||||
removed.append(f"{lowered}=")
|
||||
del tag.attrs[attr]
|
||||
continue
|
||||
if lowered in _URL_ATTRS:
|
||||
value = tag.attrs[attr]
|
||||
value_str = value if isinstance(value, str) else " ".join(value)
|
||||
if _DANGEROUS_URL_RE.match(value_str or ""):
|
||||
removed.append(f"{lowered}={value_str[:24]}")
|
||||
del tag.attrs[attr]
|
||||
|
||||
return str(soup), removed
|
||||
|
||||
|
||||
def validate_html(html: str) -> list[str]:
|
||||
"""Return a list of structural problems with ``html`` (empty == valid).
|
||||
|
||||
A page only counts as a complete document when it has the full skeleton
|
||||
(doctype/html/head/body), a non-empty ``<title>``, visible body content
|
||||
(no white-screen), no residual scripts, and no external stylesheet links.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if not html or not isinstance(html, str):
|
||||
return ["empty html"]
|
||||
lowered = html.lower()
|
||||
if "<!doctype html" not in lowered:
|
||||
errors.append("缺少 <!doctype html> 声明")
|
||||
if "<html" not in lowered:
|
||||
errors.append("缺少 <html> 根元素")
|
||||
if "<head" not in lowered:
|
||||
errors.append("缺少 <head> 元素")
|
||||
if "<body" not in lowered:
|
||||
errors.append("缺少 <body> 元素")
|
||||
if "<script" in lowered:
|
||||
errors.append("仍包含 <script> 标签(不允许脚本)")
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
# Title must exist and be non-empty.
|
||||
title_el = soup.find("title")
|
||||
if title_el is None or not title_el.get_text(strip=True):
|
||||
errors.append("缺少非空 <title> 标题")
|
||||
# External stylesheet links pull remote resources — not allowed offline.
|
||||
for link in soup.find_all("link"):
|
||||
href = str(link.get("href") or "")
|
||||
rel = " ".join(link.get("rel") or []) if isinstance(link.get("rel"), list) else str(link.get("rel") or "")
|
||||
if re.match(r"^\s*(https?:)?//", href) and "stylesheet" in rel.lower():
|
||||
errors.append("包含外链样式表(不允许外部 CDN/资源)")
|
||||
break
|
||||
# Body must carry some visible content — guards against white-screen.
|
||||
body = soup.body or soup
|
||||
text = body.get_text(strip=True) if body else ""
|
||||
has_media = bool(body.find(["img", "svg", "canvas", "table", "ul", "ol"])) if body else False
|
||||
if len(text) < 5 and not has_media:
|
||||
errors.append("页面正文为空,可能白屏")
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入数据生成一个完整的 HTML 页面。
|
||||
|
||||
用户页面要求:
|
||||
{page_prompt}
|
||||
|
||||
限制条件:
|
||||
{negative_prompt}
|
||||
|
||||
本次数据:
|
||||
{data}
|
||||
|
||||
参考页面风格:
|
||||
{reference_style}
|
||||
|
||||
参考页面布局:
|
||||
{reference_layout}
|
||||
{skill_instructions}
|
||||
要求:
|
||||
1. 只输出 JSON,不要输出 Markdown,不要输出多余解释。
|
||||
2. JSON 字段必须包含 title、html、style_summary、layout_summary。
|
||||
3. html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
|
||||
4. 所有 CSS 写在 <style> 标签内,不要引用外部 CDN 或外链样式表。
|
||||
5. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
|
||||
6. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
|
||||
7. 页面使用中文,排版适合在浏览器中直接预览。
|
||||
"""
|
||||
|
||||
_REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问题,请修复后重新输出完整页面。
|
||||
|
||||
原始页面要求:
|
||||
{requirements}
|
||||
|
||||
发现的问题:
|
||||
{errors}
|
||||
|
||||
待修复 HTML:
|
||||
{html}
|
||||
|
||||
要求:
|
||||
1. 只输出 JSON,字段包含 fixed_html、changes(修复说明数组)。
|
||||
2. fixed_html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
|
||||
3. 移除所有 <script>、iframe、外链脚本、on* 事件属性、表单提交。
|
||||
4. 保留原有内容与数据,只修复结构与安全问题。
|
||||
"""
|
||||
|
||||
_STYLE_EXTRACT_TEMPLATE = """你是一个页面风格分析助手。请分析下面的 HTML 页面,提取可复用的风格与布局特征。
|
||||
|
||||
HTML 页面:
|
||||
{html}
|
||||
|
||||
要求:
|
||||
1. 只输出 JSON,字段包含 style_summary、layout_summary、content_structure_summary、reuse_prompt。
|
||||
2. style_summary:颜色、字体、间距、圆角、阴影、视觉密度等视觉风格。
|
||||
3. layout_summary:页面分区、卡片、表格、图表、导航等布局结构。
|
||||
4. content_structure_summary:信息组织顺序与层级。
|
||||
5. reuse_prompt:一段可直接拼接到下次生成 prompt 的中文风格说明,不要包含本页的具体数据。
|
||||
"""
|
||||
|
||||
|
||||
def _clip(value: Any, limit: int) -> str:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
text = text or ""
|
||||
return text if len(text) <= limit else text[:limit] + "\n…(内容过长已截断)"
|
||||
|
||||
|
||||
def build_generation_prompt(
|
||||
*,
|
||||
page_prompt: str,
|
||||
negative_prompt: str | None,
|
||||
data: Any,
|
||||
reference_style: str | None,
|
||||
reference_layout: str | None,
|
||||
skill_instructions: str | None = None,
|
||||
data_limit: int = 16000,
|
||||
skill_limit: int = 16000,
|
||||
) -> str:
|
||||
skill_block = ""
|
||||
if skill_instructions and skill_instructions.strip():
|
||||
skill_block = "\n可用页面生成技能规范(请严格遵循):\n" + _clip(skill_instructions.strip(), skill_limit) + "\n"
|
||||
return _GENERATION_TEMPLATE.format(
|
||||
page_prompt=(page_prompt or "(未提供,使用通用看板风格)").strip(),
|
||||
negative_prompt=(negative_prompt or "无").strip(),
|
||||
data=_clip(data, data_limit),
|
||||
reference_style=(reference_style or "无").strip(),
|
||||
reference_layout=(reference_layout or "无").strip(),
|
||||
skill_instructions=skill_block,
|
||||
)
|
||||
|
||||
|
||||
def build_repair_prompt(*, html: str, errors: list[str], requirements: str | None) -> str:
|
||||
return _REPAIR_TEMPLATE.format(
|
||||
requirements=(requirements or "无").strip(),
|
||||
errors="\n".join(f"- {e}" for e in errors) or "- 未知问题",
|
||||
html=_clip(html, 32000),
|
||||
)
|
||||
|
||||
|
||||
def build_style_extract_prompt(*, html: str) -> str:
|
||||
return _STYLE_EXTRACT_TEMPLATE.format(html=_clip(html, 32000))
|
||||
|
||||
|
||||
async def screenshot_html(html: str, output_path) -> str | None:
|
||||
"""Best-effort PNG screenshot of ``html`` for white-screen / console QA.
|
||||
|
||||
Returns the screenshot path on success, or ``None`` when Playwright (or a
|
||||
usable browser) is unavailable — the offline image ships without one, so
|
||||
this must degrade silently rather than fail the run.
|
||||
"""
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch()
|
||||
try:
|
||||
page = await browser.new_page()
|
||||
await page.set_content(html, wait_until="networkidle")
|
||||
await page.screenshot(path=str(output_path), full_page=True)
|
||||
finally:
|
||||
await browser.close()
|
||||
return str(output_path)
|
||||
except Exception:
|
||||
logger.debug("Playwright screenshot failed; skipping QA screenshot", exc_info=True)
|
||||
return None
|
||||
+479
-1
@@ -22,6 +22,7 @@ from deerflow.config.agents_config import AGENT_ID_PATTERN
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from deerflow.runtime.scheduler import html_page as html_page_utils
|
||||
from deerflow.runtime.scheduler.element import ElementDeliveryClient
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.utils.time import now_iso
|
||||
@@ -36,6 +37,11 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
|
||||
# Retries allowed after the first attempt before the run is marked "failed".
|
||||
_MAX_RUN_RETRIES = 3
|
||||
|
||||
# Max HTML length (chars) inlined into the run's ``result_json``. Beyond this
|
||||
# the inline copy is dropped to stay under MySQL's 64 KB ``TEXT`` cap; the full
|
||||
# document is always retrievable from the page store (LONGTEXT) + index.html.
|
||||
_MAX_INLINE_RESULT_HTML = 60000
|
||||
|
||||
# Minimum length (in characters) the generated Markdown file's content must
|
||||
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
|
||||
# this threshold — it is enforced silently by the service.
|
||||
@@ -58,6 +64,22 @@ class MarkdownRequirementError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class HtmlGenerationError(Exception):
|
||||
"""Raised when an ``html_page`` run did not produce a complete, valid HTML
|
||||
document (missing doctype/html/head/body, empty body, or residual scripts
|
||||
after the sanitize + repair passes). The run is marked failed — a page only
|
||||
counts as successful when it is a full, previewable HTML document."""
|
||||
|
||||
|
||||
class DuplicateFavoriteError(Exception):
|
||||
"""A user already bookmarked this page. Surfaced as HTTP 409 by the router
|
||||
so the same page cannot be favorited twice."""
|
||||
|
||||
def __init__(self, page_id: str) -> None:
|
||||
self.page_id = page_id
|
||||
super().__init__(f"Page {page_id!r} is already favorited")
|
||||
|
||||
|
||||
class DuplicateTaskNameError(Exception):
|
||||
"""A user already owns a scheduled task with the requested name.
|
||||
|
||||
@@ -835,7 +857,7 @@ class ScheduledTaskService:
|
||||
runtime_thread_id = str(uuid.uuid4())
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._run_agent_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
|
||||
self._run_task_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
|
||||
timeout=_RUN_ATTEMPT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
@@ -875,6 +897,333 @@ class ScheduledTaskService:
|
||||
await self._deliver_element_result(task, result["content"])
|
||||
return
|
||||
|
||||
async def _run_task_attempt(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
*,
|
||||
runtime_thread_id: str,
|
||||
scheduled_for: datetime,
|
||||
run_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Dispatch a single execution attempt by task kind.
|
||||
|
||||
``html_page`` tasks render an HTML document via GLM5; everything else
|
||||
runs the regular lead-agent attempt. The kind lives in
|
||||
``execution_context.task_kind`` so no schema migration is needed.
|
||||
"""
|
||||
ctx = task.get("execution_context")
|
||||
kind = ctx.get("task_kind") if isinstance(ctx, dict) else None
|
||||
if kind == "html_page":
|
||||
return await self._run_html_page_attempt(
|
||||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||||
)
|
||||
return await self._run_agent_attempt(
|
||||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _html_config(task: dict[str, Any]) -> dict[str, Any]:
|
||||
ctx = task.get("execution_context")
|
||||
cfg = ctx.get("html_config") if isinstance(ctx, dict) else None
|
||||
return cfg if isinstance(cfg, dict) else {}
|
||||
|
||||
async def _invoke_chat_model(self, model_name: str | None, prompt: str) -> str:
|
||||
"""Invoke a chat model once and return its text content.
|
||||
|
||||
Used for the GLM5 HTML generation / repair / style-extraction passes.
|
||||
Thinking is disabled to keep structured-JSON output stable.
|
||||
"""
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
# No hardcoded model: use exactly the selected model; only when none was
|
||||
# selected do we fall back to the system default (config models[0]).
|
||||
model = create_chat_model(name=model_name or None, thinking_enabled=False)
|
||||
response = await model.ainvoke([HumanMessage(content=prompt)])
|
||||
content = getattr(response, "content", response)
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
|
||||
return "\n".join(p for p in parts if p)
|
||||
return str(content)
|
||||
|
||||
async def _gather_html_data(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
*,
|
||||
runtime_thread_id: str,
|
||||
scheduled_for: datetime,
|
||||
run_id: str,
|
||||
) -> str:
|
||||
"""Run the lead agent to collect source data for the HTML page.
|
||||
|
||||
Reuses the regular agent-run primitives (with the task's search query
|
||||
folded into the prompt) and returns the agent's final text, which feeds
|
||||
the GLM5 render step as ``本次数据``. Returns an empty string if the
|
||||
agent produced nothing.
|
||||
"""
|
||||
cfg = self._html_config(task)
|
||||
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
||||
gather_instruction = (
|
||||
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
|
||||
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
|
||||
)
|
||||
parts = [gather_instruction]
|
||||
if page_prompt:
|
||||
parts.append(f"页面需求:{page_prompt}")
|
||||
base_prompt = str(task.get("prompt") or "").strip()
|
||||
if base_prompt:
|
||||
parts.append(f"补充说明:{base_prompt}")
|
||||
gather_task = {**task, "prompt": "\n".join(parts)}
|
||||
# Never apply the markdown gate to the gather pass.
|
||||
gather_ctx = dict(task.get("execution_context") or {})
|
||||
gather_ctx.pop("require_markdown", None)
|
||||
gather_ctx.pop("task_kind", None)
|
||||
gather_ctx.pop("html_config", None)
|
||||
# Use the task's selected model for the data-gathering agent too, so the
|
||||
# whole HTML pipeline honors ``html_config.model_name`` instead of
|
||||
# silently falling back to the default model for this step.
|
||||
gather_model = str(cfg.get("model_name") or "").strip()
|
||||
if gather_model:
|
||||
gather_ctx["model_name"] = gather_model
|
||||
gather_task["execution_context"] = gather_ctx
|
||||
|
||||
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
|
||||
async with self._agent_run_lock:
|
||||
record = await self._start_agent_run(gather_task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
|
||||
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
|
||||
if record.task is not None:
|
||||
await record.task
|
||||
text = await self._extract_latest_ai_text(runtime_thread_id)
|
||||
return text or ""
|
||||
|
||||
def _collect_html_skill_instructions(self) -> str:
|
||||
"""Concatenate the bodies of enabled skills marked for HTML pages.
|
||||
|
||||
A skill opts in via ``metadata.kind: html_page`` in its SKILL.md
|
||||
frontmatter. Only enabled skills (per the skills settings the user
|
||||
toggles) are injected, so the render step honors the user's skill
|
||||
selection. Best-effort — any failure yields an empty string.
|
||||
"""
|
||||
try:
|
||||
from deerflow.skills import get_or_new_skill_storage
|
||||
|
||||
storage = get_or_new_skill_storage()
|
||||
skills = storage.load_skills(enabled_only=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to load skills for HTML render injection", exc_info=True)
|
||||
return ""
|
||||
blocks: list[str] = []
|
||||
for skill in skills:
|
||||
try:
|
||||
text = Path(skill.skill_file).read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
frontmatter, body = html_page_utils.parse_frontmatter(text)
|
||||
meta = frontmatter.get("metadata") if isinstance(frontmatter, dict) else None
|
||||
if not (isinstance(meta, dict) and str(meta.get("kind")) == "html_page"):
|
||||
continue
|
||||
body = body.strip()
|
||||
if body:
|
||||
blocks.append(f"### 技能:{skill.name}\n{body}")
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
def _archive_html_index(self, run_id: str, html: str) -> Path | None:
|
||||
"""Write the generated HTML to the run's archive dir as ``index.html``."""
|
||||
try:
|
||||
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id / "files"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = archive_dir / "index.html"
|
||||
target.write_text(html, encoding="utf-8")
|
||||
return target
|
||||
except Exception:
|
||||
logger.warning("Failed to archive generated HTML for run %s", run_id, exc_info=True)
|
||||
return None
|
||||
|
||||
async def _run_html_page_attempt(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
*,
|
||||
runtime_thread_id: str,
|
||||
scheduled_for: datetime,
|
||||
run_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate an HTML page for an ``html_page`` task.
|
||||
|
||||
Pipeline: gather data via the lead agent → optionally load a reference
|
||||
favorite's style/layout → GLM5 render → sanitize → validate → (one
|
||||
GLM5 repair pass if invalid) → best-effort screenshot → archive
|
||||
``index.html`` → persist a ``scheduled_html_pages`` row → build the run
|
||||
result payload (``format: "html"`` with the full HTML inline).
|
||||
"""
|
||||
cfg = self._html_config(task)
|
||||
# The user's selected model only — no hardcoded GLM-5. Empty → None →
|
||||
# system default (config models[0]).
|
||||
model_name = str(cfg.get("model_name") or "").strip() or None
|
||||
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
||||
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
|
||||
reference_favorite_id = cfg.get("reference_favorite_id") or None
|
||||
|
||||
# 1) Gather source data (real, via the agent's search tools).
|
||||
data_text = await self._gather_html_data(
|
||||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||||
)
|
||||
|
||||
# 2) Load reference style/layout from a favorite if one was selected.
|
||||
reference_style = None
|
||||
reference_layout = None
|
||||
if reference_favorite_id:
|
||||
store = self._html_store
|
||||
if store is not None:
|
||||
try:
|
||||
favorite = await store.get_favorite(str(reference_favorite_id))
|
||||
except Exception:
|
||||
favorite = None
|
||||
if favorite is not None:
|
||||
reference_style = favorite.get("reuse_prompt") or favorite.get("style_summary")
|
||||
reference_layout = favorite.get("layout_summary")
|
||||
|
||||
# 3) Render with the chosen model, injecting any enabled HTML skills.
|
||||
skill_instructions = self._collect_html_skill_instructions()
|
||||
gen_prompt = html_page_utils.build_generation_prompt(
|
||||
page_prompt=page_prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
data=data_text,
|
||||
reference_style=reference_style,
|
||||
reference_layout=reference_layout,
|
||||
skill_instructions=skill_instructions,
|
||||
)
|
||||
raw = await self._invoke_chat_model(model_name, gen_prompt)
|
||||
parsed = html_page_utils.extract_json_object(raw) or {}
|
||||
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
|
||||
html = parsed.get("html")
|
||||
if not isinstance(html, str) or "<" not in html:
|
||||
# GLM5 may have replied with bare HTML instead of JSON — accept it.
|
||||
if isinstance(raw, str) and ("<html" in raw.lower() or "<!doctype" in raw.lower()):
|
||||
html = raw
|
||||
else:
|
||||
raise ValueError("GLM5 未返回有效的 HTML(JSON 解析失败)")
|
||||
style_summary = str(parsed.get("style_summary") or "").strip() or None
|
||||
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
|
||||
|
||||
# 4) Sanitize + validate, with one repair pass on failure.
|
||||
html, _removed = html_page_utils.sanitize_html(html)
|
||||
errors = html_page_utils.validate_html(html)
|
||||
if errors:
|
||||
logger.info("Generated HTML failed validation (%s); attempting repair", errors)
|
||||
repair_prompt = html_page_utils.build_repair_prompt(
|
||||
html=html, errors=errors, requirements=page_prompt or task.get("prompt")
|
||||
)
|
||||
try:
|
||||
repaired_raw = await self._invoke_chat_model(model_name, repair_prompt)
|
||||
repaired = html_page_utils.extract_json_object(repaired_raw) or {}
|
||||
fixed = repaired.get("fixed_html")
|
||||
if isinstance(fixed, str) and "<" in fixed:
|
||||
fixed_clean, _ = html_page_utils.sanitize_html(fixed)
|
||||
if not html_page_utils.validate_html(fixed_clean):
|
||||
html = fixed_clean
|
||||
except Exception:
|
||||
logger.warning("HTML repair pass failed; keeping sanitized original", exc_info=True)
|
||||
|
||||
# 4b) Completeness gate — a page only counts as successful when it is a
|
||||
# full, valid HTML document. If it still fails validation after the
|
||||
# repair pass, fail the run (no half-baked page is archived/persisted).
|
||||
final_errors = html_page_utils.validate_html(html)
|
||||
if final_errors:
|
||||
raise HtmlGenerationError(
|
||||
"HTML 生成未通过完整性校验:" + ";".join(final_errors) + "。请调整页面要求或重试。"
|
||||
)
|
||||
|
||||
# 5) Best-effort screenshot QA (no-op without Playwright).
|
||||
screenshot_path = None
|
||||
try:
|
||||
shot_target = get_paths().base_dir / "scheduled-task-runs" / run_id / "files" / "preview.png"
|
||||
shot_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = await html_page_utils.screenshot_html(html, shot_target)
|
||||
except Exception:
|
||||
screenshot_path = None
|
||||
|
||||
# 6) Archive index.html.
|
||||
archive_path = self._archive_html_index(run_id, html)
|
||||
|
||||
files: list[dict[str, Any]] = []
|
||||
if archive_path is not None:
|
||||
files.append(
|
||||
{
|
||||
"name": "index.html",
|
||||
"path": str(archive_path),
|
||||
"virtual_path": "/mnt/user-data/outputs/index.html",
|
||||
"mime_type": "text/html",
|
||||
"size": archive_path.stat().st_size if archive_path.exists() else None,
|
||||
"kind": "html",
|
||||
}
|
||||
)
|
||||
if screenshot_path:
|
||||
shot = Path(screenshot_path)
|
||||
files.append(
|
||||
{
|
||||
"name": "preview.png",
|
||||
"path": str(shot),
|
||||
"mime_type": "image/png",
|
||||
"size": shot.stat().st_size if shot.exists() else None,
|
||||
"kind": "artifact",
|
||||
}
|
||||
)
|
||||
|
||||
data_snapshot = {"data_text": data_text[:8000] if isinstance(data_text, str) else ""}
|
||||
prompt_snapshot = {
|
||||
"page_prompt": page_prompt,
|
||||
"negative_prompt": negative_prompt,
|
||||
"reference_favorite_id": reference_favorite_id,
|
||||
"model_name": model_name,
|
||||
}
|
||||
|
||||
# 7) Persist the page asset (best-effort — failure must not fail the run).
|
||||
store = self._html_store
|
||||
if store is not None:
|
||||
try:
|
||||
await store.create_page(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"task_id": task["task_id"],
|
||||
"run_id": run_id,
|
||||
"user_id": task["user_id"],
|
||||
"title": title,
|
||||
"html_content": html,
|
||||
"html_file_path": str(archive_path) if archive_path else None,
|
||||
"data_snapshot": data_snapshot,
|
||||
"prompt_snapshot": prompt_snapshot,
|
||||
"reference_favorite_id": reference_favorite_id,
|
||||
"style_summary": style_summary,
|
||||
"layout_summary": layout_summary,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist scheduled_html_pages row for run %s", run_id, exc_info=True)
|
||||
|
||||
task_tz = ZoneInfo(task.get("timezone") or "UTC")
|
||||
local_time = scheduled_for.astimezone(task_tz)
|
||||
# ``scheduled_task_runs.result_json`` is TEXT (64 KB) on MySQL, so only
|
||||
# inline the HTML when it comfortably fits. The full document always
|
||||
# lives in ``scheduled_html_pages.html_content`` (LONGTEXT) + the
|
||||
# archived ``index.html``; the ``/runs/{id}/html`` endpoints read from
|
||||
# there, so previews work even when the inline copy is omitted here.
|
||||
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
|
||||
return {
|
||||
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
|
||||
"content": page_prompt or f"HTML 页面已生成:{title}",
|
||||
"format": "html",
|
||||
"html": inline_html,
|
||||
"style_summary": style_summary,
|
||||
"layout_summary": layout_summary,
|
||||
"data_snapshot": data_snapshot,
|
||||
"prompt_snapshot": prompt_snapshot,
|
||||
"files": files,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
async def _run_agent_attempt(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
@@ -1275,3 +1624,132 @@ class ScheduledTaskService:
|
||||
|
||||
async def unsubscribe_task(self, user_id: str, task_id: str) -> bool:
|
||||
return await self.store.delete_subscription(task_id, user_id)
|
||||
|
||||
# --- HTML pages / favorites ---
|
||||
|
||||
@property
|
||||
def _html_store(self):
|
||||
state = getattr(self.app, "state", None)
|
||||
return getattr(state, "html_page_store", None)
|
||||
|
||||
async def get_html_page_for_run(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""Return the generated HTML page for a run (page-store row or run result)."""
|
||||
store = self._html_store
|
||||
if store is not None:
|
||||
page = await store.get_page_by_run(run_id)
|
||||
if page is not None:
|
||||
return page
|
||||
# Fallback to the inline HTML on the run result.
|
||||
run = await self.store.get_run_any(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
result = run.get("result") if isinstance(run.get("result"), dict) else None
|
||||
if not result or not isinstance(result.get("html"), str):
|
||||
return None
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"task_id": run.get("task_id"),
|
||||
"title": result.get("title"),
|
||||
"html_content": result.get("html"),
|
||||
"style_summary": result.get("style_summary"),
|
||||
"layout_summary": result.get("layout_summary"),
|
||||
"created_at": result.get("created_at"),
|
||||
}
|
||||
|
||||
async def get_latest_html_page_for_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
store = self._html_store
|
||||
if store is not None:
|
||||
page = await store.latest_page_for_task(task_id)
|
||||
if page is not None:
|
||||
return page
|
||||
# Fallback: newest run that carries inline HTML.
|
||||
runs = await self.store.list_runs_any(task_id, limit=20)
|
||||
for run in runs:
|
||||
result = run.get("result") if isinstance(run.get("result"), dict) else None
|
||||
if result and isinstance(result.get("html"), str):
|
||||
return await self.get_html_page_for_run(run["id"])
|
||||
return None
|
||||
|
||||
async def list_html_page_favorites(self, user_id: str) -> list[dict[str, Any]]:
|
||||
store = self._html_store
|
||||
if store is None:
|
||||
return []
|
||||
return await store.list_favorites(user_id)
|
||||
|
||||
async def get_html_page_favorite(self, user_id: str, favorite_id: str) -> dict[str, Any] | None:
|
||||
store = self._html_store
|
||||
if store is None:
|
||||
return None
|
||||
favorite = await store.get_favorite(favorite_id)
|
||||
if favorite is None or favorite.get("user_id") != user_id:
|
||||
return None
|
||||
return favorite
|
||||
|
||||
async def delete_html_page_favorite(self, user_id: str, favorite_id: str) -> bool:
|
||||
store = self._html_store
|
||||
if store is None:
|
||||
return False
|
||||
return await store.delete_favorite(favorite_id, user_id)
|
||||
|
||||
async def create_html_page_favorite(
|
||||
self,
|
||||
user_id: str,
|
||||
page_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Bookmark a generated page, extracting a reusable style summary via GLM5.
|
||||
|
||||
Returns ``None`` when the page does not exist or is not owned by the
|
||||
caller. Style extraction is best-effort — a failure still produces a
|
||||
favorite (without the reuse hints) so the user's bookmark is never lost.
|
||||
"""
|
||||
store = self._html_store
|
||||
if store is None:
|
||||
return None
|
||||
page = await store.get_page(page_id)
|
||||
if page is None or page.get("user_id") != user_id:
|
||||
return None
|
||||
# A page can only be bookmarked once per user.
|
||||
existing = await store.get_favorite_by_page(user_id, page_id)
|
||||
if existing is not None:
|
||||
raise DuplicateFavoriteError(page_id)
|
||||
html = page.get("html_content") or ""
|
||||
|
||||
style_summary = page.get("style_summary")
|
||||
layout_summary = page.get("layout_summary")
|
||||
content_structure_summary = None
|
||||
reuse_prompt = None
|
||||
if html:
|
||||
try:
|
||||
# Reuse the model the page was generated with (recorded in the
|
||||
# page's prompt snapshot) so favoriting doesn't fall back to the
|
||||
# default model.
|
||||
extract_model = (page.get("prompt_snapshot") or {}).get("model_name")
|
||||
extract_prompt = html_page_utils.build_style_extract_prompt(html=html)
|
||||
raw = await self._invoke_chat_model(extract_model, extract_prompt)
|
||||
extracted = html_page_utils.extract_json_object(raw) or {}
|
||||
style_summary = extracted.get("style_summary") or style_summary
|
||||
layout_summary = extracted.get("layout_summary") or layout_summary
|
||||
content_structure_summary = extracted.get("content_structure_summary")
|
||||
reuse_prompt = extracted.get("reuse_prompt")
|
||||
except Exception:
|
||||
logger.warning("HTML style extraction failed for page %s; saving favorite without hints", page_id, exc_info=True)
|
||||
|
||||
return await store.create_favorite(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"page_id": page_id,
|
||||
"title": (title or page.get("title") or "收藏页面").strip(),
|
||||
"description": (description or None),
|
||||
"tags": tags or [],
|
||||
"html_snapshot": html,
|
||||
"style_summary": style_summary,
|
||||
"layout_summary": layout_summary,
|
||||
"content_structure_summary": content_structure_summary,
|
||||
"reuse_prompt": reuse_prompt,
|
||||
}
|
||||
)
|
||||
+1
-1
@@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str:
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": query,
|
||||
"source_ids": [source_ids],
|
||||
"source_ids": source_ids,
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": top_k,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Regression tests for the upstream call shape of token-exchange login.
|
||||
|
||||
The upstream ``getTokenInfo`` endpoint reads the bearer token from the
|
||||
``Authorization`` header — NOT from an ``access_token`` cookie. These tests pin
|
||||
that contract for ``_fetch_username_from_token``:
|
||||
|
||||
- ``Authorization`` is exactly ``Bearer <caller-token>`` (the token passed in),
|
||||
not the static ``service_authorization`` config value.
|
||||
- No ``access_token`` cookie is sent (the old, wrong placement).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.routers import auth as auth_module
|
||||
from app.gateway.routers.auth import _fetch_username_from_token
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"state": "200", "msg": "ok", "data": {"username": "b1admin"}}
|
||||
|
||||
|
||||
class _CapturingClient:
|
||||
"""Stand-in for ``httpx.AsyncClient`` that records the post() kwargs."""
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
type(self).init_kwargs = kwargs
|
||||
|
||||
async def __aenter__(self) -> _CapturingClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url, **kwargs):
|
||||
type(self).captured = {"url": url, **kwargs}
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
def _patch_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
token_login = types.SimpleNamespace(
|
||||
token_info_url="https://upstream.example/getTokenInfo",
|
||||
service_authorization="Bearer admin", # must NOT be used anymore
|
||||
timeout_seconds=10.0,
|
||||
verify_ssl=False,
|
||||
ca_cert_path="",
|
||||
)
|
||||
fake_cfg = types.SimpleNamespace(auth_login=types.SimpleNamespace(token_login=token_login))
|
||||
monkeypatch.setattr(auth_module, "get_app_config", lambda: fake_cfg)
|
||||
monkeypatch.setattr(auth_module.httpx, "AsyncClient", _CapturingClient)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_caller_token_as_bearer_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_config(monkeypatch)
|
||||
|
||||
username = await _fetch_username_from_token("UPSTREAM_TOK_123")
|
||||
|
||||
assert username == "b1admin"
|
||||
headers = _CapturingClient.captured["headers"]
|
||||
assert headers["Authorization"] == "Bearer UPSTREAM_TOK_123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_use_static_service_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_config(monkeypatch)
|
||||
|
||||
await _fetch_username_from_token("UPSTREAM_TOK_123")
|
||||
|
||||
headers = _CapturingClient.captured["headers"]
|
||||
assert headers["Authorization"] != "Bearer admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_access_token_cookie_is_sent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_config(monkeypatch)
|
||||
|
||||
await _fetch_username_from_token("UPSTREAM_TOK_123")
|
||||
|
||||
# The token must live in the header now; no cookie param should be passed.
|
||||
assert "cookies" not in _CapturingClient.captured
|
||||
Generated
-4917
File diff suppressed because it is too large.
Load diff
@@ -151,11 +151,12 @@ auth_login:
|
||||
require_password: false
|
||||
password: ""
|
||||
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
|
||||
# (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在
|
||||
# 库里时自动注册。
|
||||
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
|
||||
# 用户名不在库里时自动注册。
|
||||
token_login:
|
||||
enabled: true
|
||||
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
|
||||
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
|
||||
service_authorization: "Bearer admin"
|
||||
timeout_seconds: 10
|
||||
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
|
||||
|
||||
Reference in new issue
Block a user