diff --git a/frontend-web/package.json b/frontend-web/package.json index 77e59056..1613e744 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -60,6 +60,7 @@ "docx": "^9.5.1", "dotenv": "^17.2.3", "embla-carousel-react": "^8.6.0", + "file-saver": "^2.0.5", "gsap": "^3.13.0", "hast": "^1.0.0", "katex": "^0.16.28", @@ -92,6 +93,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.0.15", + "@types/file-saver": "^2.0.7", "@types/gsap": "^3.0.0", "@types/node": "^20.14.10", "@types/react": "^19.0.0", diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 88704d11..63ff8e22 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -37,6 +37,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useI18n } from "@/core/i18n/hooks"; +import { + normalizeSearchResults, + resolveSearchQuery, + type SearchResultItem, +} from "@/core/messages/search-results"; import { extractReasoningContentFromMessage, findToolCallResult, @@ -216,15 +221,21 @@ function ToolCall({ const { t } = useI18n(); const artifacts = useArtifactsOptional(); - if (name === "web_search") { + const searchItems = normalizeSearchResults(result); + if (name === "web_search" || searchItems.length > 0) { + const query = resolveSearchQuery(args, result); let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; - if (typeof args.query === "string") { - label = t.toolCalls.searchOnWebFor(args.query); + if (query) { + label = + name === "web_search" + ? t.toolCalls.searchOnWebFor(query) + : t.toolCalls.searchFor(query); } - const items = normalizeSearchResults(result); return ( - {items.length > 0 && } + {searchItems.length > 0 && ( + + )} ); } else if (name === "image_search") { @@ -501,32 +512,7 @@ function convertToSteps(messages: Message[]): CoTStep[] { return steps; } -type WebSearchItem = { - title?: string; - url?: string; - snippet?: string; - content?: string; - published_date?: string; - publishedDate?: string; - date?: string; -}; - -function normalizeSearchResults( - result: string | Record | undefined, -): WebSearchItem[] { - if (Array.isArray(result)) { - return result as WebSearchItem[]; - } - if (result && typeof result === "object") { - const nested = (result as { results?: unknown }).results; - if (Array.isArray(nested)) { - return nested as WebSearchItem[]; - } - } - return []; -} - -function WebSearchResultsList({ results }: { results: WebSearchItem[] }) { +function WebSearchResultsList({ results }: { results: SearchResultItem[] }) { const [open, setOpen] = useState(false); return ( @@ -548,16 +534,16 @@ function WebSearchResultsList({ results }: { results: WebSearchItem[] }) { ); } -function SearchResultCard({ item }: { item: WebSearchItem }) { +function SearchResultCard({ item }: { item: SearchResultItem }) { const [open, setOpen] = useState(false); const content = item.snippet ?? item.content; const date = item.published_date ?? item.publishedDate ?? item.date; - let source: string | undefined; + let sourceLabel: string | undefined = item.source; if (item.url) { try { - source = new URL(item.url).hostname.replace(/^www\./, ""); + sourceLabel = new URL(item.url).hostname.replace(/^www\./, ""); } catch { - source = undefined; + sourceLabel = sourceLabel ?? item.url; } } return ( @@ -572,10 +558,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) { {item.title} )} - {(source || date) && ( + {(sourceLabel || date) && (
- {source && {source}} - {source && date && ·} + {sourceLabel && {sourceLabel}} + {sourceLabel && date && ·} {date && {date}}
)} @@ -591,10 +577,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) { {item.title ?? "检索结果"} - {(source || date) && ( + {(sourceLabel || date) && ( - {source && {source}} - {source && date && ·} + {sourceLabel && {sourceLabel}} + {sourceLabel && date && ·} {date && {date}} )} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index ed0dd614..ff7ac125 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; // import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; -import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react"; +import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon } from "lucide-react"; import { SidebarCollapseButton } from "./workspace-header"; import { cn } from "@/lib/utils"; @@ -107,6 +107,18 @@ export function WorkspaceNavChatList({ + + + + + AI写作 + + + | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value) || /^www\./i.test(value); +} + +function collectNestedRecords( + record: Record, +): Record[] { + const layers: Record[] = [record]; + for (const key of NESTED_META_KEYS) { + const nested = asRecord(record[key]); + if (nested) { + layers.push(nested); + } + } + return layers; +} + +function pickFromLayers( + layers: Record[], + aliases: readonly string[], +): string | undefined { + for (const layer of layers) { + for (const alias of aliases) { + const value = pickString(layer[alias]); + if (value) { + return value; + } + } + } + return undefined; +} + +function pickByKeyPattern( + layers: Record[], + pattern: RegExp, + options?: { excludeUrl?: boolean; maxLength?: number }, +): string | undefined { + for (const layer of layers) { + for (const [key, value] of Object.entries(layer)) { + if (SKIP_HEURISTIC_KEYS.has(key.toLowerCase())) { + continue; + } + if (!pattern.test(key)) { + continue; + } + const text = pickString(value); + if (!text) { + continue; + } + if (options?.excludeUrl && looksLikeUrl(text)) { + continue; + } + if (options?.maxLength !== undefined && text.length > options.maxLength) { + continue; + } + return text; + } + } + return undefined; +} + +function pickLongestContentFallback( + layers: Record[], + reserved: Set, +): string | undefined { + let best: string | undefined; + for (const layer of layers) { + for (const [key, value] of Object.entries(layer)) { + const lower = key.toLowerCase(); + if (SKIP_HEURISTIC_KEYS.has(lower)) { + continue; + } + if (TITLE_KEY_PATTERN.test(key) || URL_KEY_PATTERN.test(key)) { + continue; + } + const text = pickString(value); + if (!text || text.length < 8) { + continue; + } + if (reserved.has(text)) { + continue; + } + if (looksLikeUrl(text) && text.length < 120) { + continue; + } + if (!best || text.length > best.length) { + best = text; + } + } + } + return best; +} + +function pickShortTitleFallback( + layers: Record[], + reserved: Set, +): string | undefined { + let best: string | undefined; + for (const layer of layers) { + for (const [key, value] of Object.entries(layer)) { + if (URL_KEY_PATTERN.test(key) || CONTENT_KEY_PATTERN.test(key)) { + continue; + } + const text = pickString(value); + if (!text || text.length > 200 || text.length < 1) { + continue; + } + if (reserved.has(text)) { + continue; + } + if (looksLikeUrl(text)) { + continue; + } + if (!best || text.length < best.length) { + best = text; + } + } + } + return best; +} + +function normalizeSearchResultItem(raw: unknown): SearchResultItem | null { + const record = asRecord(raw); + if (!record) { + return null; + } + + const layers = collectNestedRecords(record); + + let title = + pickFromLayers(layers, TITLE_ALIASES) ?? + pickByKeyPattern(layers, TITLE_KEY_PATTERN, { maxLength: 200 }); + let content = + pickFromLayers(layers, CONTENT_ALIASES) ?? + pickByKeyPattern(layers, CONTENT_KEY_PATTERN, { excludeUrl: true }); + let url = + pickFromLayers(layers, URL_ALIASES) ?? + pickByKeyPattern(layers, URL_KEY_PATTERN); + let source = + pickFromLayers(layers, SOURCE_ALIASES) ?? + pickByKeyPattern(layers, SOURCE_KEY_PATTERN, { + excludeUrl: true, + maxLength: 120, + }); + const date = + pickFromLayers(layers, DATE_ALIASES) ?? + pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 }); + + const reserved = new Set(); + for (const value of [title, content, url, source, date]) { + if (value) { + reserved.add(value); + } + } + + if (!content) { + content = pickLongestContentFallback(layers, reserved); + if (content) { + reserved.add(content); + } + } + if (!title) { + title = pickShortTitleFallback(layers, reserved); + } + + if (!title && !content && !url) { + return null; + } + + return { + title, + url, + snippet: content, + content, + source, + published_date: date, + publishedDate: date, + date, + }; +} + +function extractRawResults(payload: unknown, depth = 0): unknown[] | null { + if (depth > 4) { + return null; + } + + if (Array.isArray(payload)) { + return payload.length > 0 ? payload : null; + } + + const record = asRecord(payload); + if (!record) { + return null; + } + + for (const key of RESULT_LIST_KEYS) { + const nested = record[key]; + if (Array.isArray(nested) && nested.length > 0) { + return nested; + } + } + + for (const key of WRAPPER_KEYS) { + const nested = record[key]; + const found = extractRawResults(nested, depth + 1); + if (found) { + return found; + } + } + + return null; +} + +function parseResultPayload( + result: string | Record | undefined, +): unknown { + if (result === undefined) { + return undefined; + } + if (typeof result === "string") { + const trimmed = result.trim(); + if (!trimmed) { + return undefined; + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + return undefined; + } + } + return result; +} + +/** True when the tool return value looks like a retrieval hit list. */ +export function hasSearchResultPayload( + result: string | Record | undefined, +): boolean { + return normalizeSearchResults(result).length > 0; +} + +export function normalizeSearchResults( + result: string | Record | undefined, +): SearchResultItem[] { + const payload = parseResultPayload(result); + const rawList = extractRawResults(payload); + if (!rawList) { + return []; + } + + const items: SearchResultItem[] = []; + for (const raw of rawList) { + const item = normalizeSearchResultItem(raw); + if (item) { + items.push(item); + } + } + return items; +} + +export function resolveSearchQuery( + args: Record, + result: string | Record | undefined, +): string | undefined { + if (typeof args.query === "string" && args.query.trim()) { + return args.query.trim(); + } + const payload = asRecord(parseResultPayload(result)); + if (!payload) { + return undefined; + } + const queryKeys = ["query", "q", "keyword", "keywords", "search_query"] as const; + for (const key of queryKeys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return undefined; +} diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index a11a70dc..19d27d2c 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende // ── Edit mode ───────────────────────────────────────────────────────── return (
- +
{/* 左侧目录栏(仿豆包):文档标题层级导航 */} diff --git a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx index 7eaaca0c..1c9202d9 100644 --- a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx +++ b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx @@ -12,7 +12,11 @@ import { Quote, Code, Pilcrow, + Download, + Loader2, } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; import { @@ -21,6 +25,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { CanvasModelSelector } from "../selectors/CanvasModelSelector"; +import { exportMarkdownToDocx } from "../../utils/export-docx"; interface ToolbarButtonProps { onClick: () => void; @@ -60,11 +65,32 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar interface WritingToolbarProps { editor: Editor | null; + /** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */ + markdown?: string; + /** 文档标题,用作 .docx 文件名与封面 */ + title?: string; } -export function WritingToolbar({ editor }: WritingToolbarProps) { +export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) { + const [exporting, setExporting] = useState(false); + if (!editor) return null; + const canExport = !exporting && !!markdown?.trim(); + + const handleExport = async () => { + if (!canExport) return; + setExporting(true); + try { + await exportMarkdownToDocx(markdown ?? "", title); + } catch (err) { + console.error("[export-docx] failed", err); + toast.error("导出 Word 失败,请稍后重试"); + } finally { + setExporting(false); + } + }; + return (
{/* Block type */} @@ -173,8 +199,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) { - {/* Model selector — pushed to the right */} -
+ {/* Right side: export + model selector */} +
+ + + + + + 导出 Word + + + + +
diff --git a/frontend-web/src/open-canvas/utils/export-docx.ts b/frontend-web/src/open-canvas/utils/export-docx.ts new file mode 100644 index 00000000..71fe8d87 --- /dev/null +++ b/frontend-web/src/open-canvas/utils/export-docx.ts @@ -0,0 +1,345 @@ +/** + * Markdown → Word(.docx) 导出工具。 + * + * 设计要点: + * - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。 + * - 文档结构:封面标题(无 H1 时补) → 正文。 + */ +import { + AlignmentType, + Document, + ExternalHyperlink, + HeadingLevel, + LevelFormat, + Packer, + Paragraph, + TextRun, +} from "docx"; +import { saveAs } from "file-saver"; + +/** 标题层级(H1~H3) */ +type HeadingLevelNum = 1 | 2 | 3; + +interface ParsedHeading { + level: HeadingLevelNum; + text: string; +} + +type Block = + | { kind: "heading"; level: HeadingLevelNum; text: string } + | { kind: "paragraph"; text: string } + | { kind: "blockquote"; text: string } + | { kind: "code"; code: string } + | { kind: "list"; ordered: boolean; items: string[] }; + +// ──────────────────────────────────────────────────────────────────────────── +// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法 +// ──────────────────────────────────────────────────────────────────────────── + +const RE_HEADING = /^(#{1,3})\s+(.*)$/; +const RE_FENCE = /^```/; +const RE_QUOTE = /^>\s?/; +const RE_UL = /^[-*]\s+/; +const RE_OL = /^\d+\.\s+/; +const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/; + +function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const blocks: Block[] = []; + const headings: ParsedHeading[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + if (RE_FENCE.test(line)) { + const buf: string[] = []; + i++; + while (i < lines.length && !RE_FENCE.test(lines[i])) { + buf.push(lines[i]); + i++; + } + if (i < lines.length) i++; // 跳过闭合 ``` + blocks.push({ kind: "code", code: buf.join("\n") }); + continue; + } + + const hm = RE_HEADING.exec(line); + if (hm) { + const level = hm[1].length as HeadingLevelNum; + const text = hm[2].trim(); + headings.push({ level, text }); + blocks.push({ kind: "heading", level, text }); + i++; + continue; + } + + if (RE_QUOTE.test(line)) { + const buf: string[] = []; + while (i < lines.length && RE_QUOTE.test(lines[i])) { + buf.push(lines[i].replace(RE_QUOTE, "")); + i++; + } + blocks.push({ kind: "blockquote", text: buf.join("\n") }); + continue; + } + + if (RE_UL.test(line)) { + const items: string[] = []; + while (i < lines.length && RE_UL.test(lines[i])) { + items.push(lines[i].replace(RE_UL, "")); + i++; + } + blocks.push({ kind: "list", ordered: false, items }); + continue; + } + + if (RE_OL.test(line)) { + const items: string[] = []; + while (i < lines.length && RE_OL.test(lines[i])) { + items.push(lines[i].replace(RE_OL, "")); + i++; + } + blocks.push({ kind: "list", ordered: true, items }); + continue; + } + + // 段落:合并相邻非空、非块起始行 + const buf: string[] = [line]; + i++; + while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) { + buf.push(lines[i]); + i++; + } + blocks.push({ kind: "paragraph", text: buf.join("\n") }); + } + + return { blocks, headings }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// 行内格式 → TextRun / Hyperlink +// ──────────────────────────────────────────────────────────────────────────── + +/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */ +const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g; + +type InlineChild = TextRun | ExternalHyperlink; + +function parseInline(text: string): InlineChild[] { + if (!text) return [new TextRun({ text: "" })]; + + const out: InlineChild[] = []; + let last = 0; + let m: RegExpExecArray | null; + RE_INLINE.lastIndex = 0; + + while ((m = RE_INLINE.exec(text)) !== null) { + if (m.index > last) { + out.push(new TextRun({ text: text.slice(last, m.index) })); + } + const tok = m[0]; + if (tok.startsWith("**")) { + out.push(new TextRun({ text: tok.slice(2, -2), bold: true })); + } else if (tok.startsWith("`")) { + out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" })); + } else if (tok.startsWith("[")) { + const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok); + if (linkMatch) { + out.push( + new ExternalHyperlink({ + link: linkMatch[2], + children: [ + new TextRun({ + text: linkMatch[1], + color: "0563C1", + underline: {}, + }), + ], + }), + ); + } else { + out.push(new TextRun({ text: tok })); + } + } else if (tok.startsWith("*") || tok.startsWith("_")) { + out.push(new TextRun({ text: tok.slice(1, -1), italics: true })); + } + last = RE_INLINE.lastIndex; + } + if (last < text.length) { + out.push(new TextRun({ text: text.slice(last) })); + } + return out; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Block → Paragraph[] +// ──────────────────────────────────────────────────────────────────────────── + +const DOCX_HEADING: Record = { + 1: HeadingLevel.HEADING_1, + 2: HeadingLevel.HEADING_2, + 3: HeadingLevel.HEADING_3, +}; + +function blockToParagraphs(b: Block): Paragraph[] { + switch (b.kind) { + case "heading": { + return [ + new Paragraph({ + heading: DOCX_HEADING[b.level], + children: parseInline(b.text), + }), + ]; + } + case "paragraph": { + return [ + new Paragraph({ + children: parseInline(b.text), + spacing: { line: 360, after: 120 }, + }), + ]; + } + case "blockquote": { + return [ + new Paragraph({ + children: parseInline(b.text), + indent: { left: 480 }, + spacing: { line: 360, after: 120 }, + }), + ]; + } + case "code": { + return b.code.split("\n").map( + (line) => + new Paragraph({ + children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })], + }), + ); + } + case "list": { + return b.items.map( + (item) => + new Paragraph({ + numbering: { + reference: b.ordered ? "ol" : "ul", + level: 0, + }, + children: parseInline(item), + spacing: { line: 320 }, + }), + ); + } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// 文件名工具 +// ──────────────────────────────────────────────────────────────────────────── + +function sanitizeFilename(name: string): string { + // Windows 非法字符 + 控制字符 → 下划线 + return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim(); +} + +function fallbackFilename(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; +} + +function resolveFilename(title: string | undefined): string { + const base = title ? sanitizeFilename(title) : ""; + return `${base || fallbackFilename()}.docx`; +} + +// ──────────────────────────────────────────────────────────────────────────── +// 主入口 +// ──────────────────────────────────────────────────────────────────────────── + +/** + * 把 markdown 文档导出为 .docx 并触发浏览器下载。 + * + * @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown) + * @param title 文档标题,用作文件名与封面(空则用时间戳兜底) + */ +export async function exportMarkdownToDocx( + markdown: string, + title?: string, +): Promise { + const { blocks, headings } = parseMarkdown(markdown ?? ""); + + // 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1 + const coverText = + (title && title.trim()) || + headings.find((h) => h.level === 1)?.text || + "未命名文档"; + + const children: Paragraph[] = []; + + // 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复) + const hasFirstH1 = headings[0]?.level === 1; + if (!hasFirstH1) { + children.push( + new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 240, after: 240 }, + children: [new TextRun({ text: coverText, bold: true, size: 36 })], + }), + ); + } + + // 正文 + for (const b of blocks) { + children.push(...blockToParagraphs(b)); + } + + const doc = new Document({ + creator: "DeerFlow AI 写作", + title: coverText, + styles: { + default: { + document: { + run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑 + }, + }, + }, + numbering: { + config: [ + { + reference: "ul", + levels: [ + { + level: 0, + format: LevelFormat.BULLET, + text: "•", + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 360, hanging: 240 } } }, + }, + ], + }, + { + reference: "ol", + levels: [ + { + level: 0, + format: LevelFormat.DECIMAL, + text: "%1.", + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 360, hanging: 240 } } }, + }, + ], + }, + ], + }, + sections: [{ children }], + }); + + const blob = await Packer.toBlob(doc); + saveAs(blob, resolveFilename(title)); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 2471e375..9afd198f 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -31,6 +31,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminMemoryPage from "./AdminMemoryPage"; import TagsPage from "./TagsPage"; import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; +import AIWritingPage from "@/open-canvas/pages/AIWritingPage"; export function WorkspaceLayout({ children, @@ -177,6 +178,14 @@ export default function WorkspaceRoutes({ } /> + + + + } + /> } diff --git a/offline-backend-20260512/backend/uv.lock b/offline-backend-20260512/backend/uv.lock index 4b2a7b64..deb9f383 100644 --- a/offline-backend-20260512/backend/uv.lock +++ b/offline-backend-20260512/backend/uv.lock @@ -780,12 +780,14 @@ name = "deer-flow" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "asyncmy" }, { name = "bcrypt" }, { name = "croniter" }, { name = "deerflow-harness" }, { name = "dingtalk-stream" }, { name = "email-validator" }, { name = "fastapi" }, + { name = "hindsight-client" }, { name = "httpx" }, { name = "langgraph-sdk" }, { name = "lark-oapi" }, @@ -817,6 +819,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncmy", specifier = ">=0.2.11" }, { name = "bcrypt", specifier = ">=4.0.0" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "deerflow-harness", editable = "packages/harness" }, @@ -825,6 +828,7 @@ requires-dist = [ { name = "dingtalk-stream", specifier = ">=0.24.3" }, { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "hindsight-client", specifier = ">=0.4.22" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "langgraph-sdk", specifier = ">=0.1.51" }, { name = "lark-oapi", specifier = ">=1.4.0" },