Merge branch 'dhk-1' into 'master'
Dhk 1 See merge request knowledge-base-bj/deerflow-server!43
This commit is contained in:
commit
d59e0514b3
9 files changed
+888
-46
No files matched your search
@@ -60,6 +60,7 @@
|
|||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/gsap": "^3.0.0",
|
"@types/gsap": "^3.0.0",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import {
|
||||||
|
normalizeSearchResults,
|
||||||
|
resolveSearchQuery,
|
||||||
|
type SearchResultItem,
|
||||||
|
} from "@/core/messages/search-results";
|
||||||
import {
|
import {
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
@@ -216,15 +221,21 @@ function ToolCall({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const artifacts = useArtifactsOptional();
|
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;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
if (typeof args.query === "string") {
|
if (query) {
|
||||||
label = t.toolCalls.searchOnWebFor(args.query);
|
label =
|
||||||
|
name === "web_search"
|
||||||
|
? t.toolCalls.searchOnWebFor(query)
|
||||||
|
: t.toolCalls.searchFor(query);
|
||||||
}
|
}
|
||||||
const items = normalizeSearchResults(result);
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||||||
{items.length > 0 && <WebSearchResultsList results={items} />}
|
{searchItems.length > 0 && (
|
||||||
|
<WebSearchResultsList results={searchItems} />
|
||||||
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
} else if (name === "image_search") {
|
} else if (name === "image_search") {
|
||||||
@@ -501,32 +512,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
return steps;
|
return steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchItem = {
|
function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
snippet?: string;
|
|
||||||
content?: string;
|
|
||||||
published_date?: string;
|
|
||||||
publishedDate?: string;
|
|
||||||
date?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeSearchResults(
|
|
||||||
result: string | Record<string, unknown> | 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[] }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||||
@@ -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 [open, setOpen] = useState(false);
|
||||||
const content = item.snippet ?? item.content;
|
const content = item.snippet ?? item.content;
|
||||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||||
let source: string | undefined;
|
let sourceLabel: string | undefined = item.source;
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
try {
|
try {
|
||||||
source = new URL(item.url).hostname.replace(/^www\./, "");
|
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||||
} catch {
|
} catch {
|
||||||
source = undefined;
|
sourceLabel = sourceLabel ?? item.url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -572,10 +558,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
|
|||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(source || date) && (
|
{(sourceLabel || date) && (
|
||||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
{source && <span className="truncate">{source}</span>}
|
{sourceLabel && <span className="truncate">{sourceLabel}</span>}
|
||||||
{source && date && <span>·</span>}
|
{sourceLabel && date && <span>·</span>}
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -591,10 +577,10 @@ function SearchResultCard({ item }: { item: WebSearchItem }) {
|
|||||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
<DialogTitle className="pr-6 text-base leading-relaxed">
|
||||||
{item.title ?? "检索结果"}
|
{item.title ?? "检索结果"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{(source || date) && (
|
{(sourceLabel || date) && (
|
||||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||||
{source && <span>{source}</span>}
|
{sourceLabel && <span>{sourceLabel}</span>}
|
||||||
{source && date && <span>·</span>}
|
{sourceLabel && date && <span>·</span>}
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
// 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 { SidebarCollapseButton } from "./workspace-header";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -107,6 +107,18 @@ export function WorkspaceNavChatList({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={pathname.startsWith("/page/workspace/ai-writing")}
|
||||||
|
asChild
|
||||||
|
className={NAV_ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<Link href="/page/workspace/ai-writing">
|
||||||
|
<PenLine />
|
||||||
|
<span>AI写作</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
/**
|
||||||
|
* Normalizes retrieval payloads from web_search, intranet skills, MCP tools, etc.
|
||||||
|
* into a single card-friendly shape for the workspace chat UI.
|
||||||
|
*
|
||||||
|
* Parsing is intentionally tolerant: known field aliases first, then key-name
|
||||||
|
* heuristics, then longest-string fallback for body text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SearchResultItem = {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
snippet?: string;
|
||||||
|
content?: string;
|
||||||
|
source?: string;
|
||||||
|
published_date?: string;
|
||||||
|
publishedDate?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_LIST_KEYS = [
|
||||||
|
"results",
|
||||||
|
"data",
|
||||||
|
"items",
|
||||||
|
"hits",
|
||||||
|
"documents",
|
||||||
|
"records",
|
||||||
|
"entries",
|
||||||
|
"matches",
|
||||||
|
"chunks",
|
||||||
|
"sources",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WRAPPER_KEYS = ["result", "payload", "response", "output", "data"] as const;
|
||||||
|
|
||||||
|
const NESTED_META_KEYS = ["metadata", "meta", "attrs", "attributes", "properties", "extra"] as const;
|
||||||
|
|
||||||
|
/** Keys ignored when guessing content from arbitrary string fields. */
|
||||||
|
const SKIP_HEURISTIC_KEYS = new Set([
|
||||||
|
"id",
|
||||||
|
"uuid",
|
||||||
|
"recuuid",
|
||||||
|
"score",
|
||||||
|
"relevance_score",
|
||||||
|
"rank",
|
||||||
|
"index",
|
||||||
|
"type",
|
||||||
|
"status",
|
||||||
|
"ok",
|
||||||
|
"success",
|
||||||
|
"error",
|
||||||
|
"query",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TITLE_ALIASES = [
|
||||||
|
"title",
|
||||||
|
"m_title",
|
||||||
|
"name",
|
||||||
|
"heading",
|
||||||
|
"headline",
|
||||||
|
"subject",
|
||||||
|
"document_title",
|
||||||
|
"doc_title",
|
||||||
|
"file_name",
|
||||||
|
"filename",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTENT_ALIASES = [
|
||||||
|
"content",
|
||||||
|
"content_preview",
|
||||||
|
"snippet",
|
||||||
|
"page_content",
|
||||||
|
"body",
|
||||||
|
"text",
|
||||||
|
"summary",
|
||||||
|
"abstract",
|
||||||
|
"description",
|
||||||
|
"desc",
|
||||||
|
"excerpt",
|
||||||
|
"preview",
|
||||||
|
"passage",
|
||||||
|
"paragraph",
|
||||||
|
"chunk",
|
||||||
|
"chunk_text",
|
||||||
|
"document",
|
||||||
|
"doc_content",
|
||||||
|
"answer",
|
||||||
|
"highlight",
|
||||||
|
"context",
|
||||||
|
"message",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const URL_ALIASES = ["url", "href", "link", "uri", "source_url", "web_url", "page_url"] as const;
|
||||||
|
|
||||||
|
const SOURCE_ALIASES = [
|
||||||
|
"source",
|
||||||
|
"source1",
|
||||||
|
"source_name",
|
||||||
|
"origin",
|
||||||
|
"site",
|
||||||
|
"site_name",
|
||||||
|
"publisher",
|
||||||
|
"domain",
|
||||||
|
"from",
|
||||||
|
"channel",
|
||||||
|
"database",
|
||||||
|
"knowledge_base",
|
||||||
|
"kb",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DATE_ALIASES = [
|
||||||
|
"published_date",
|
||||||
|
"publishedDate",
|
||||||
|
"publish_date",
|
||||||
|
"date",
|
||||||
|
"m_publish",
|
||||||
|
"published_at",
|
||||||
|
"publish_time",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"time",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TITLE_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:title|name|heading|headline|subject)(?:$|_)/i;
|
||||||
|
const CONTENT_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:content|snippet|summary|abstract|body|text|preview|passage|excerpt|desc|description|chunk|paragraph|document|answer|highlight|context|message)(?:$|_)/i;
|
||||||
|
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||||
|
const SOURCE_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||||
|
const DATE_KEY_PATTERN =
|
||||||
|
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||||
|
|
||||||
|
function pickString(...values: unknown[]): string | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(value) || /^www\./i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNestedRecords(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const layers: Record<string, unknown>[] = [record];
|
||||||
|
for (const key of NESTED_META_KEYS) {
|
||||||
|
const nested = asRecord(record[key]);
|
||||||
|
if (nested) {
|
||||||
|
layers.push(nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFromLayers(
|
||||||
|
layers: Record<string, unknown>[],
|
||||||
|
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<string, unknown>[],
|
||||||
|
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<string, unknown>[],
|
||||||
|
reserved: Set<string>,
|
||||||
|
): 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<string, unknown>[],
|
||||||
|
reserved: Set<string>,
|
||||||
|
): 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<string>();
|
||||||
|
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<string, unknown> | 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<string, unknown> | undefined,
|
||||||
|
): boolean {
|
||||||
|
return normalizeSearchResults(result).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSearchResults(
|
||||||
|
result: string | Record<string, unknown> | 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<string, unknown>,
|
||||||
|
result: string | Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
@@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
<WritingToolbar editor={editor} />
|
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
|
||||||
|
|
||||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||||
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
Quote,
|
Quote,
|
||||||
Code,
|
Code,
|
||||||
Pilcrow,
|
Pilcrow,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +25,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||||
|
import { exportMarkdownToDocx } from "../../utils/export-docx";
|
||||||
|
|
||||||
interface ToolbarButtonProps {
|
interface ToolbarButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -60,11 +65,32 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
|
|||||||
|
|
||||||
interface WritingToolbarProps {
|
interface WritingToolbarProps {
|
||||||
editor: Editor | null;
|
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;
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
||||||
{/* Block type */}
|
{/* Block type */}
|
||||||
@@ -173,8 +199,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) {
|
|||||||
<Strikethrough className="h-3.5 w-3.5" />
|
<Strikethrough className="h-3.5 w-3.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
{/* Model selector — pushed to the right */}
|
{/* Right side: export + model selector */}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleExport();
|
||||||
|
}}
|
||||||
|
disabled={!canExport}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
导出 Word
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||||
|
|
||||||
<CanvasModelSelector />
|
<CanvasModelSelector />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<HeadingLevelNum, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||||
|
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<void> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import AdminUserActivityPage from "./AdminUserActivityPage";
|
|||||||
import AdminMemoryPage from "./AdminMemoryPage";
|
import AdminMemoryPage from "./AdminMemoryPage";
|
||||||
import TagsPage from "./TagsPage";
|
import TagsPage from "./TagsPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
|
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||||
|
|
||||||
export function WorkspaceLayout({
|
export function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
@@ -177,6 +178,14 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="ai-writing"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<AIWritingPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="scheduled"
|
path="scheduled"
|
||||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||||
|
|||||||
Generated
+4
@@ -780,12 +780,14 @@ name = "deer-flow"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "asyncmy" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
{ name = "deerflow-harness" },
|
{ name = "deerflow-harness" },
|
||||||
{ name = "dingtalk-stream" },
|
{ name = "dingtalk-stream" },
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "hindsight-client" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "langgraph-sdk" },
|
{ name = "langgraph-sdk" },
|
||||||
{ name = "lark-oapi" },
|
{ name = "lark-oapi" },
|
||||||
@@ -817,6 +819,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "asyncmy", specifier = ">=0.2.11" },
|
||||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
{ name = "croniter", specifier = ">=6.2.2" },
|
{ name = "croniter", specifier = ">=6.2.2" },
|
||||||
{ name = "deerflow-harness", editable = "packages/harness" },
|
{ name = "deerflow-harness", editable = "packages/harness" },
|
||||||
@@ -825,6 +828,7 @@ requires-dist = [
|
|||||||
{ name = "dingtalk-stream", specifier = ">=0.24.3" },
|
{ name = "dingtalk-stream", specifier = ">=0.24.3" },
|
||||||
{ name = "email-validator", specifier = ">=2.0.0" },
|
{ name = "email-validator", specifier = ">=2.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
|
{ name = "hindsight-client", specifier = ">=0.4.22" },
|
||||||
{ name = "httpx", specifier = ">=0.28.0" },
|
{ name = "httpx", specifier = ">=0.28.0" },
|
||||||
{ name = "langgraph-sdk", specifier = ">=0.1.51" },
|
{ name = "langgraph-sdk", specifier = ">=0.1.51" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.0" },
|
{ name = "lark-oapi", specifier = ">=1.4.0" },
|
||||||
|
|||||||
Reference in new issue
Block a user