diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 06579a79..0968e8ff 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -4,3 +4,6 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api + +# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离) +VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 306dc883..945d2878 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -21,11 +21,13 @@ import { History, Layout, Library, + List, ListChecks, ListTodo, MessageSquareCode, MessageSquarePlus, Network, + NotebookPen, Plus, Settings, Sparkles, @@ -81,10 +83,23 @@ const menuItems: MenuItem[] = [ icon: Grid3X3, children: [ { - id: "light-app-new-note", - label: "新建笔记", - icon: Plus, - path: "/page/strategy/light-app/new-note", + id: "light-app-note-management", + label: "笔记管理", + icon: NotebookPen, + children: [ + { + id: "light-app-new-note", + label: "新建笔记", + icon: Plus, + path: "/page/strategy/light-app/new-note", + }, + { + id: "light-app-note-list", + label: "笔记列表", + icon: List, + path: "/page/strategy/light-app/notes", + }, + ], }, { id: "light-app-square", label: "应用广场", icon: Layout }, { id: "light-app-records", label: "运行记录", icon: History }, @@ -208,6 +223,8 @@ const menuItems: MenuItem[] = [ const defaultExpandedIds = new Set([ "qa-center", + "light-app-center", + "light-app-note-management", "task-management", "xk-center-task", ]); diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 928a1339..1eb5cc28 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -68,3 +68,13 @@ export function getLangGraphBaseURL(isMock?: boolean) { return "http://localhost:2026/api/langgraph"; } } + +/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */ +export function getNoteApiBaseURL(): string { + if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) { + return new URL(env.NEXT_PUBLIC_NOTE_API_BASE_URL, getBaseOrigin()) + .toString() + .replace(/\/+$/, ""); + } + return ""; +} diff --git a/frontend-web/src/core/note/api.ts b/frontend-web/src/core/note/api.ts new file mode 100644 index 00000000..71a1c2cc --- /dev/null +++ b/frontend-web/src/core/note/api.ts @@ -0,0 +1,103 @@ +import { getNoteApiBaseURL } from "@/core/config"; + +import { + ensureNoteAuth, + getNoteAuthorizationHeader, + getStoredNoteAuth, +} from "./auth"; + +export type { NoteAuth } from "./auth"; + +export interface SearchSpace { + created_at: string; + id: number; + name: string; + description: string; + user_id: string; + citations_enabled: boolean; + qna_custom_instructions: string; + summary: string | null; + avatar_url: string | null; + sources_limit: number; + sources_used: number; + is_shared: boolean; + is_owner: boolean; + owner_email: string | null; + member_count?: number; + source_count?: number; + membership_role?: string; +} + +export interface ListSearchSpacesQuery { + owned_only?: boolean; + limit?: number; + order_by?: string; +} + +export interface CreateSearchSpaceInput { + name: string; + description?: string; + citations_enabled?: boolean; +} + +export async function noteApiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const auth = getStoredNoteAuth() ?? (await ensureNoteAuth()); + + const headers = new Headers(init.headers); + headers.set("Authorization", getNoteAuthorizationHeader(auth)); + + return fetch(`${getNoteApiBaseURL()}${path}`, { + ...init, + headers, + }); +} + +export async function createSearchSpace( + input: CreateSearchSpaceInput, +): Promise { + await ensureNoteAuth(); + + const response = await noteApiFetch("/api/v1/searchspaces", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: input.name, + description: input.description ?? "", + citations_enabled: input.citations_enabled ?? true, + }), + }); + + if (!response.ok) { + throw new Error(`创建笔记失败: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as SearchSpace; +} + +export async function listSearchSpaces( + query: ListSearchSpacesQuery = {}, +): Promise { + await ensureNoteAuth(); + + const params = new URLSearchParams({ + owned_only: String(query.owned_only ?? true), + limit: String(query.limit ?? 1000), + order_by: query.order_by ?? "created_at", + }); + + const response = await noteApiFetch(`/api/v1/searchspaces?${params.toString()}`, { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`获取笔记列表失败: ${response.status} ${response.statusText}`); + } + + const data: unknown = await response.json(); + return Array.isArray(data) ? (data as SearchSpace[]) : []; +} diff --git a/frontend-web/src/core/note/auth.ts b/frontend-web/src/core/note/auth.ts new file mode 100644 index 00000000..fca3c650 --- /dev/null +++ b/frontend-web/src/core/note/auth.ts @@ -0,0 +1,94 @@ +import { getNoteApiBaseURL } from "@/core/config"; + +const NOTE_AUTH_STORAGE_KEY = "deerflow.note.auth"; + +const NOTE_LOGIN_USERNAME = "dhk@qq.com"; +const NOTE_LOGIN_PASSWORD = "1qaz@WSX"; + +export interface NoteAuth { + access_token: string; + token_type: string; +} + +function isBrowser(): boolean { + return typeof window !== "undefined"; +} + +function assertNoteApiBaseURL(): string { + const baseURL = getNoteApiBaseURL(); + if (!baseURL) { + throw new Error("未配置 VITE_NOTE_API_BASE_URL"); + } + return baseURL; +} + +export function getStoredNoteAuth(): NoteAuth | null { + if (!isBrowser()) { + return null; + } + const raw = window.localStorage.getItem(NOTE_AUTH_STORAGE_KEY); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as NoteAuth; + } catch { + return null; + } +} + +export function setStoredNoteAuth(auth: NoteAuth): void { + if (!isBrowser()) { + return; + } + window.localStorage.setItem(NOTE_AUTH_STORAGE_KEY, JSON.stringify(auth)); +} + +export function clearStoredNoteAuth(): void { + if (!isBrowser()) { + return; + } + window.localStorage.removeItem(NOTE_AUTH_STORAGE_KEY); +} + +export function getNoteAuthorizationHeader(auth: NoteAuth): string { + const token = auth.access_token.trim(); + const tokenType = auth.token_type?.trim() || "Bearer"; + return `${tokenType} ${token}`; +} + +export async function loginNoteApi(): Promise { + const formData = new FormData(); + formData.append("username", NOTE_LOGIN_USERNAME); + formData.append("password", NOTE_LOGIN_PASSWORD); + formData.append("grant_type", "password"); + + const response = await fetch(`${assertNoteApiBaseURL()}/auth/jwt/login`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`笔记登录失败: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as Partial; + if (!data.access_token) { + throw new Error("笔记登录失败: 未返回 access_token"); + } + + const auth: NoteAuth = { + access_token: data.access_token, + token_type: data.token_type?.trim() || "bearer", + }; + setStoredNoteAuth(auth); + return auth; +} + +export async function ensureNoteAuth(): Promise { + const existing = getStoredNoteAuth(); + if (existing?.access_token) { + return existing; + } + return loginNoteApi(); +} diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index e387c0f2..5e1d7c01 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -5,6 +5,7 @@ function readEnv( | "VITE_BACKEND_BASE_URL" | "VITE_LANGGRAPH_BASE_URL" | "VITE_CANVAS_LANGGRAPH_BASE_URL" + | "VITE_NOTE_API_BASE_URL" | "VITE_STATIC_WEBSITE_ONLY" | "VITE_GITHUB_OAUTH_TOKEN", ): EnvValue { @@ -18,6 +19,7 @@ export const env = { // Canvas page uses its own LangGraph base URL so it can be independently // 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_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), }; diff --git a/frontend-web/src/hooks/useNoteAuth.ts b/frontend-web/src/hooks/useNoteAuth.ts new file mode 100644 index 00000000..529181d3 --- /dev/null +++ b/frontend-web/src/hooks/useNoteAuth.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState } from "react"; + +import { loginNoteApi, type NoteAuth } from "@/core/note/auth"; + +export function useNoteAuth() { + const [auth, setAuth] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refreshAuth = useCallback(async () => { + setLoading(true); + setError(null); + try { + const nextAuth = await loginNoteApi(); + setAuth(nextAuth); + return nextAuth; + } catch (err) { + const message = + err instanceof Error ? err.message : "笔记登录失败,请稍后重试"; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refreshAuth().catch(() => { + // 错误已在 refreshAuth 中写入 state + }); + }, [refreshAuth]); + + return { + auth, + loading, + error, + refreshAuth, + }; +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 8c488573..de5a4eec 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -40,6 +40,8 @@ import { AIWritingTimeline } from './AIWritingTimeline'; import { InterventionCard } from './InterventionCard'; import { WritingSkeletonCard } from './WritingSkeletonCard'; import { DraftStreamingView } from './DraftStreamingView'; +import { IntentResultCard } from './IntentResultCard'; +import { IntentStreamingView } from './IntentStreamingView'; import { OutlineStreamingView } from './OutlineStreamingView'; import { ReviewStreamingView } from './ReviewStreamingView'; import { RevisionSummaryCard } from './RevisionSummaryCard'; @@ -343,6 +345,17 @@ export function AIWritingPanel() { )} + {/* 意图解析:流式 → 结果卡片承接。 + 放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长, + 如果把意图块塞在 timeline 下面就会随 timeline 增长被推走; + 放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */} + {(state.intentStreamingText || state.intentThinkingText) && ( + + )} + {state.parsedIntent && !state.intentStreamingText && ( + + )} + {/* 运行中或完成后显示时间线 */} {!isIdle && ( 0) && ( + !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && + /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ + !state.intentStreamingText && !state.intentThinkingText && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx new file mode 100644 index 00000000..316cb59d --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/IntentResultCard.tsx @@ -0,0 +1,51 @@ +import { Sparkles } from 'lucide-react'; + +import type { ParsedIntent } from '../../types/ai-writing'; +import type { ArticleType } from '../../api/ai-writing-article-types'; + +interface IntentResultCardProps { + intent: ParsedIntent; + articleTypes: ArticleType[]; +} + +/** + * 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项: + * 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。 + */ +export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) { + // article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道") + const typeLabel = + articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType; + + return ( +
+
+
+ + + 意图解析完成 + +
+
+ + + + {intent.refinedIntent && ( + + )} +
+
+
+ ); +} + +function Row({ label, value, multiline }: { label: string; value: string; multiline?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/IntentStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/IntentStreamingView.tsx new file mode 100644 index 00000000..8be413d8 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/IntentStreamingView.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; +import { Sparkles, ChevronDown, ChevronRight, Brain } from 'lucide-react'; + +interface IntentStreamingViewProps { + text: string; + thinkingText?: string; +} + +/** + * 意图解析流式视图:与 OutlineStreamingView / DraftStreamingView 同样的打字机机制, + * 颜色用蓝色主题区分意图理解阶段。展示 LLM 边吐 JSON 边滚动的过程,结果由 + * IntentResultCard 接力展示结构化字段。 + */ +export function IntentStreamingView({ text, thinkingText }: IntentStreamingViewProps) { + const preRef = useRef(null); + const thinkRef = useRef(null); + const [thinkOpen, setThinkOpen] = useState(true); + + useEffect(() => { + if (preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }, [text]); + + useEffect(() => { + if (thinkOpen && thinkRef.current) { + thinkRef.current.scrollTop = thinkRef.current.scrollHeight; + } + }, [thinkingText, thinkOpen]); + + if (!text && !thinkingText) return null; + + return ( +
+ {thinkingText && ( +
+ + {thinkOpen && ( +
+              {thinkingText}
+              {!text && (
+                
+              )}
+            
+ )} +
+ )} + + {text && ( +
+
+ + + 意图解析中… + + + {[0, 1, 2].map((i) => ( + + ))} + +
+
+            {text}
+            
+          
+
+ )} + + +
+ ); +} diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index fcfe3614..1b21eb16 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -36,6 +36,7 @@ import type { AIOutline, BlockedSection, CompletedIntervention, + ParsedIntent, ProgressEvent, ProgressEventType, ReviewIssue, @@ -66,6 +67,9 @@ const INITIAL_STATE: AIWritingState = { streamingDraftSections: [], outlineStreamingText: '', outlineThinkingText: '', + intentStreamingText: '', + intentThinkingText: '', + parsedIntent: null, researchProgress: '', streamingReviewItems: [], revisionSummary: null, @@ -146,6 +150,41 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState case 'step_done': return base; + case 'intent_chunk': { + const text = (event as unknown as { text: string }).text ?? ''; + const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; + return { + ...prev, + intentStreamingText: isThinking ? prev.intentStreamingText : prev.intentStreamingText + text, + intentThinkingText: isThinking ? prev.intentThinkingText + text : prev.intentThinkingText, + }; + } + + case 'intent_ready': { + const ev = event as unknown as { + articleType?: string; + article_type?: string; + targetAudience?: string; + target_audience?: string; + wordCountTarget?: number; + word_count_target?: number; + refinedIntent?: string; + refined_intent?: string; + }; + const parsed: ParsedIntent = { + articleType: ev.articleType ?? ev.article_type ?? '', + targetAudience: ev.targetAudience ?? ev.target_audience ?? '', + wordCountTarget: Number(ev.wordCountTarget ?? ev.word_count_target ?? 800), + refinedIntent: ev.refinedIntent ?? ev.refined_intent ?? '', + }; + return { + ...base, + parsedIntent: parsed, + intentStreamingText: '', + intentThinkingText: '', + }; + } + case 'research_progress': return { ...base, researchProgress: event.message ?? '' }; @@ -403,6 +442,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | // 后端 _save_draft_to_repo 等 helper 不走 custom 通道,type 必为节点 emit 的 chunk 类型 if (!type) return null; const allowed: ProgressEventType[] = [ + 'intent_chunk', 'intent_ready', 'research_progress', 'outline_chunk', 'draft_chunk', 'review_chunk', 'review_item_done', ]; diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 93ca8760..339676eb 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -84,6 +84,8 @@ export type ProgressEventType = | 'connected' | 'step_start' | 'step_done' + | 'intent_chunk' + | 'intent_ready' | 'research_progress' | 'materials_ready' | 'outline_chunk' @@ -213,6 +215,14 @@ export interface CompletedIntervention { // ── Hook 状态 ───────────────────────────────────────────────────────────────── +/** 意图解析最终结构化字段,来自 intent_ready 事件。 */ +export interface ParsedIntent { + articleType: string; + targetAudience: string; + wordCountTarget: number; + refinedIntent: string; +} + export interface AIWritingState { sessionId: string | null; status: WritingStatus; @@ -234,6 +244,10 @@ export interface AIWritingState { // 流式大纲(正文 / 思考分开) outlineStreamingText: string; outlineThinkingText: string; + // 意图解析(流式 + 结果) + intentStreamingText: string; + intentThinkingText: string; + parsedIntent: ParsedIntent | null; // 素材收集实时进度(最新一条提示文案) researchProgress: string; // 流式审核(逐条到达) diff --git a/frontend-web/src/pages/LightAppNewNotePage.tsx b/frontend-web/src/pages/LightAppNewNotePage.tsx index 22facec4..79c545c5 100644 --- a/frontend-web/src/pages/LightAppNewNotePage.tsx +++ b/frontend-web/src/pages/LightAppNewNotePage.tsx @@ -1,17 +1,43 @@ "use client"; import { useMemo, useState } from "react"; -import { ArrowRight, NotebookPen, Sparkles } from "lucide-react"; +import { ArrowRight, Loader2, NotebookPen, Sparkles } from "lucide-react"; +import { toast } from "sonner"; import { useTheme } from "@/contexts/ThemeContext"; +import { createSearchSpace } from "@/core/note/api"; +import { useNoteAuth } from "@/hooks/useNoteAuth"; import { cn } from "@/lib/utils"; export default function LightAppNewNotePage() { const { theme } = useTheme(); const isDark = theme === "dark"; + const { loading: authLoading, error: authError } = useNoteAuth(); const [titleInput, setTitleInput] = useState(""); + const [creating, setCreating] = useState(false); const hasTitle = useMemo(() => titleInput.trim().length > 0, [titleInput]); + const isBusy = authLoading || creating; + + const handleCreate = async () => { + const name = titleInput.trim(); + if (!name || isBusy || authError) { + return; + } + + setCreating(true); + try { + const searchSpace = await createSearchSpace({ name }); + toast.success(`笔记「${searchSpace.name}」创建成功`); + setTitleInput(""); + } catch (error) { + const message = + error instanceof Error ? error.message : "创建笔记失败,请稍后重试"; + toast.error(message); + } finally { + setCreating(false); + } + }; return (
- {/* 固定标题:新建笔记 */}
- {/* 标题输入框 */}
setTitleInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} placeholder="新建笔记" + disabled={isBusy} className={cn( "min-w-0 flex-1 bg-transparent text-[42px] leading-tight font-normal tracking-tight outline-none", hasTitle @@ -56,20 +87,25 @@ export default function LightAppNewNotePage() { {hasTitle && ( )}
- {/* 说明文字 */}
新建笔记开始使用 + {authError ? `(${authError})` : ""}

diff --git a/frontend-web/src/pages/LightAppNoteListPage.tsx b/frontend-web/src/pages/LightAppNoteListPage.tsx new file mode 100644 index 00000000..17ca89ca --- /dev/null +++ b/frontend-web/src/pages/LightAppNoteListPage.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BookOpen, + Grid3X3, + LayoutList, + Loader2, + MoreHorizontal, + Plus, + RefreshCw, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { useTheme } from "@/contexts/ThemeContext"; +import { listSearchSpaces, type SearchSpace } from "@/core/note/api"; +import { useNoteAuth } from "@/hooks/useNoteAuth"; +import { cn } from "@/lib/utils"; + +type ViewMode = "grid" | "list"; + +function formatCreatedAt(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +function getSourceCount(note: SearchSpace): number { + return note.source_count ?? note.sources_used ?? 0; +} + +const cardThemes = [ + "bg-[#e4f2f6]", + "bg-[#f0eaf3]", + "bg-[#edf4e6]", + "bg-[#f5eadf]", + "bg-[#e9edf8]", +]; + +function NoteIcon({ isDark }: { isDark: boolean }) { + return ( +
+ +
+ ); +} + +export default function LightAppNoteListPage() { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const navigate = useNavigate(); + const { loading: authLoading, error: authError } = useNoteAuth(); + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState("grid"); + + const loadNotes = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listSearchSpaces({ + owned_only: true, + limit: 1000, + order_by: "created_at", + }); + setNotes(data); + } catch (err) { + const message = + err instanceof Error ? err.message : "获取笔记列表失败,请稍后重试"; + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (authLoading || authError) { + return; + } + void loadNotes(); + }, [authError, authLoading, loadNotes]); + + const isBusy = authLoading || loading; + + const createButtonClass = cn( + "group text-center transition-all hover:-translate-y-0.5", + isDark + ? "border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:bg-slate-900" + : "border-slate-200 bg-white text-slate-600 shadow-sm hover:border-sky-200 hover:shadow-md", + ); + + const renderCreateButton = (layout: ViewMode) => { + if (layout === "grid") { + return ( + + ); + } + + return ( + + ); + }; + + const renderNoteCard = (note: SearchSpace, index: number) => ( +
+ + + + +

+ {note.name} +

+

+ {formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源 +

+
+ ); + + const renderNoteRow = (note: SearchSpace) => ( +
+ + +
+

{note.name}

+

+ {formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源 +

+
+ + +
+ ); + + return ( +
+
+
+

+ 我的笔记本 +

+ +
+
+ + +
+ +
+
+ + {authError && ( +
+ {authError} +
+ )} + + {error && !authError && ( +
+ {error} +
+ )} + + {isBusy && !authError && ( +
+ +
+ )} + + {!isBusy && !authError && !error && viewMode === "grid" && ( +
+ {renderCreateButton("grid")} + {notes.map((note, index) => renderNoteCard(note, index))} + {notes.length === 0 && ( +
+ 暂无笔记 +
+ )} +
+ )} + + {!isBusy && !authError && !error && viewMode === "list" && ( +
+ {renderCreateButton("list")} + {notes.map((note) => renderNoteRow(note))} + {notes.length === 0 && ( +
+ 暂无笔记 +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index aa1b8039..9fb4ffc3 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -25,6 +25,7 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminUserActivityPage from "./AdminUserActivityPage"; import TagsPage from "./TagsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; +import LightAppNoteListPage from "./LightAppNoteListPage"; export default function StrategyRoutes() { return ( @@ -39,6 +40,7 @@ export default function StrategyRoutes() { {/* 轻应用 */} } /> + } /> {/* 对话 */} } /> diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 93b2e88a..0ebaa9a9 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -144,8 +144,9 @@ checkpointer: # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 memory: enabled: true - provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) - # builtin 始终启用,这里只决定 external 选哪个 + provider: + hindsight # 'builtin' | 'hindsight'(外部 provider 名) + # builtin 始终启用,这里只决定 external 选哪个 builtin: enabled: true memory_char_limit: 2200 @@ -159,10 +160,10 @@ memory: # 注意:写成 $VAR 但环境变量未设置会导致配置加载失败 bank_id_template: "deerflow-user-{user_id}" work_bank_id_template: "deerflow-work-{user_id}-{agent_id}" - memory_mode: context # 'context' | 'tools' | 'hybrid' - bank_mission: "" - bank_retain_mission: "" - recall_budget: mid # 'low' | 'mid' | 'high' + memory_mode: context # 'context' | 'tools' | 'hybrid' + bank_mission: "你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。" + bank_retain_mission: "请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。" + recall_budget: mid # 'low' | 'mid' | 'high' recall_max_tokens: 4096 recall_max_input_chars: 800 auto_recall: true @@ -188,10 +189,10 @@ memory: max_tokens: 2000 include_builtin: true include_hindsight: true - context_tag: "memory-context" # 包裹 Hindsight recall 的标签名 + context_tag: "memory-context" # 包裹 Hindsight recall 的标签名 security: - scan_content: true # 注入/外渗模式扫描 + scan_content: true # 注入/外渗模式扫描 block_invisible_unicode: true # streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。 # streaming_scrubber: true # SSE 流前清洗 @@ -200,6 +201,6 @@ memory: # 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config() # (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。 ai_writing: - keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型 - rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型 - max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20 + keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型 + rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型 + max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index a8345734..e3845ce8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -6,8 +6,16 @@ import re from langchain_core.runnables import RunnableConfig +from langchain_core.messages import HumanMessage, SystemMessage + from ..state import AIWritingState -from ._utils import llm_json +from ._utils import ( + ThinkTagParser, + _content_to_text, + get_model, + parse_json, + safe_get_stream_writer, +) logger = logging.getLogger(__name__) @@ -138,42 +146,79 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d if fallback_type not in valid_keys: fallback_type = default_key - result = await llm_json( - system, - user, - fallback={ - "article_type": fallback_type, - "target_audience": state.get("target_audience", "普通读者"), - "word_count_target": state.get("word_count_target", 800), - "refined_intent": state["user_intent"], - }, - config=config, - ) + fallback_result = { + "article_type": fallback_type, + "target_audience": state.get("target_audience", "普通读者"), + "word_count_target": state.get("word_count_target", 800), + "refined_intent": state["user_intent"], + } - # 意图解析模型调用失败时以非致命 notice 提示用户(llm_json 已用 fallback 兜底, - # 流程仍可继续,但用户需知道结构化配置可能不准)。 + # 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON, + # 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果, + # 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。 + writer = safe_get_stream_writer() + llm_error: str | None = None + full_text = "" + parser = ThinkTagParser() + try: + model = get_model(config) + async for chunk in model.astream( + [SystemMessage(content=system), HumanMessage(content=user)] + ): + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + full_text += text + writer({ + "type": "intent_chunk", + "text": text, + "is_thinking": is_thinking, + }) + except Exception as exc: + # 模型 / 网络挂掉 —— 不阻断流程,用 fallback 继续。 + logger.error("intent_parser streaming failed: %s", exc, exc_info=True) + llm_error = f"{type(exc).__name__}: {exc}" + + result = parse_json(full_text, fallback_result) if full_text else fallback_result + + # 意图解析模型调用失败时以非致命 notice 提示用户(fallback 已兜底,流程仍可继续) progress_events: list[dict] = [] - if result.get("_llm_error"): + if llm_error: progress_events.append({ "type": "notice", "agent_name": "系统", "title": "模型错误", - "detail": f"意图解析失败,已按默认配置兜底。原因:{result['_llm_error']}", + "detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}", }) - progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) - - updates: dict = {"status": "researching", "progress_events": progress_events} # 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值 raw_type = result.get("article_type", "") - updates["article_type"] = raw_type if raw_type in valid_keys else fallback_type - - if result.get("target_audience"): - updates["target_audience"] = result["target_audience"] + final_article_type = raw_type if raw_type in valid_keys else fallback_type + final_target_audience = result.get("target_audience") or fallback_result["target_audience"] word_count = _coerce_word_count(result.get("word_count_target")) + final_word_count = word_count if word_count is not None else fallback_result["word_count_target"] + final_refined_intent = result.get("refined_intent") or state["user_intent"] + + # 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图 + writer({ + "type": "intent_ready", + "article_type": final_article_type, + "target_audience": final_target_audience, + "word_count_target": final_word_count, + "refined_intent": final_refined_intent, + }) + + progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) + + updates: dict = {"status": "researching", "progress_events": progress_events} + updates["article_type"] = final_article_type + if result.get("target_audience"): + updates["target_audience"] = final_target_audience if word_count is not None: - updates["word_count_target"] = word_count + updates["word_count_target"] = final_word_count if result.get("refined_intent"): - updates["user_intent"] = result["refined_intent"] + updates["user_intent"] = final_refined_intent return updates diff --git a/offline-backend-20260512/backend/pyproject.toml b/offline-backend-20260512/backend/pyproject.toml index d4ea5346..45a21bbb 100644 --- a/offline-backend-20260512/backend/pyproject.toml +++ b/offline-backend-20260512/backend/pyproject.toml @@ -1,51 +1,53 @@ -[project] -name = "deer-flow" -version = "0.1.0" -description = "LangGraph-based AI agent system with sandbox execution capabilities" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "deerflow-harness", - "fastapi>=0.115.0", - "httpx>=0.28.0", - "python-multipart>=0.0.26", - "sse-starlette>=2.1.0", - "uvicorn[standard]>=0.34.0", - "lark-oapi>=1.4.0", - "slack-sdk>=3.33.0", - "python-telegram-bot>=21.0", - "langgraph-sdk>=0.1.51", - "markdown-to-mrkdwn>=0.3.1", - "wecom-aibot-python-sdk>=0.1.6", - "dingtalk-stream>=0.24.3", - "bcrypt>=4.0.0", - "pyjwt>=2.9.0", - "email-validator>=2.0.0", - "croniter>=6.2.2", -] - +[project] +name = "deer-flow" +version = "0.1.0" +description = "LangGraph-based AI agent system with sandbox execution capabilities" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "deerflow-harness", + "fastapi>=0.115.0", + "httpx>=0.28.0", + "python-multipart>=0.0.26", + "sse-starlette>=2.1.0", + "uvicorn[standard]>=0.34.0", + "lark-oapi>=1.4.0", + "slack-sdk>=3.33.0", + "python-telegram-bot>=21.0", + "langgraph-sdk>=0.1.51", + "markdown-to-mrkdwn>=0.3.1", + "wecom-aibot-python-sdk>=0.1.6", + "dingtalk-stream>=0.24.3", + "bcrypt>=4.0.0", + "pyjwt>=2.9.0", + "email-validator>=2.0.0", + "croniter>=6.2.2", + "hindsight-client>=0.4.22", + "asyncmy>=0.2.11", +] + [project.optional-dependencies] postgres = ["deerflow-harness[postgres]"] mysql = ["deerflow-harness[mysql]"] - -[dependency-groups] -dev = [ - "prompt-toolkit>=3.0.0", - "pytest>=9.0.3", - "pytest-asyncio>=1.3.0", - "ruff>=0.14.11", -] - -[tool.pytest.ini_options] -markers = [ - "no_auto_user: disable the conftest autouse contextvar fixture for this test", -] - -[tool.uv] -index-url = "https://pypi.org/simple" - -[tool.uv.workspace] -members = ["packages/harness"] - -[tool.uv.sources] -deerflow-harness = { workspace = true } + +[dependency-groups] +dev = [ + "prompt-toolkit>=3.0.0", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.11", +] + +[tool.pytest.ini_options] +markers = [ + "no_auto_user: disable the conftest autouse contextvar fixture for this test", +] + +[tool.uv] +index-url = "https://pypi.org/simple" + +[tool.uv.workspace] +members = ["packages/harness"] + +[tool.uv.sources] +deerflow-harness = { workspace = true }