merge: 合并 origin/xuhd,解决 message-list-item 和 WorkspaceRoutes 冲突

- message-list-item: 保留 EmbeddedPageContext + 消息内联编辑两套功能
- WorkspaceRoutes: 保留 IframeChatPage + NotebookDetailPageWithKey

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-06-03 11:41:49 +08:00
commit 712326387f
20 files changed
+859 -231

No files matched your search

+2
View File
@@ -25,6 +25,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
@@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-[1.35rem] border border-[#ece7dd] bg-white shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
"flex flex-col overflow-hidden rounded-[1.35rem] border border-border bg-background shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
className,
)}
{...props}
@@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
"flex items-center justify-between border-b border-border bg-background px-4 py-3",
className,
)}
{...props}
@@ -147,7 +147,7 @@ export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
<div
ref={ref}
className={cn(
"min-h-0 flex-1 overflow-auto bg-white p-4",
"min-h-0 flex-1 overflow-auto bg-background p-4",
className,
)}
{...props}
@@ -384,14 +384,14 @@ const ChatBox: React.FC<{
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
"bg-background transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
"h-full bg-background p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
@@ -371,6 +371,18 @@ function MessageContent_({
onEditResubmit?: (text: string) => void;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
// Streamdown splits content into per-block processors; calling the factory
// once here ensures all blocks share the same transformer instance (and thus
// the same citation counter) for this message.
const stableExtraPlugins = useMemo(() => {
if (!extraRehypePlugins?.length) return [];
return extraRehypePlugins.map((factory) => {
const transformer = factory();
return () => transformer;
});
}, [extraRehypePlugins]);
const rehypePlugins = useMemo(
() =>
extraRehypePlugins?.length
@@ -19,6 +19,9 @@ import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenL
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
import { env } from "@/env";
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -165,7 +168,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
</>
);
}
@@ -34,8 +34,6 @@ const NAV_ITEM_CLASS =
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
const SIDEBAR_PREVIEW_COUNT = 5;
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
@@ -71,6 +69,7 @@ export function WorkspaceNavStudio() {
return null;
}
const SIDEBAR_PREVIEW_COUNT = 2;
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
return (
+1 -1
View File
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
}
}
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
+2 -2
View File
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
const citeCounter = new Map<number, number>();
let citeIndex = 0;
return (tree: Root) => {
visit(tree, "text", (node, index, parent) => {
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
CITE_RE.lastIndex = 0;
const newNodes: ElementContent[] = [];
const citeCounter = new Map<number, number>();
let citeIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
+68 -6
View File
@@ -7,7 +7,7 @@ function base(): string {
export async function fetchStudioToken(email: string): Promise<string> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
@@ -22,7 +22,7 @@ export async function fetchStudioToken(email: string): Promise<string> {
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` },
@@ -36,7 +36,7 @@ export async function createSearchSpace(
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
method: "POST",
@@ -46,7 +46,47 @@ export async function createSearchSpace(
},
body: JSON.stringify({ name, description: "", citations_enabled: true }),
});
if (!res.ok) throw new Error(`创建失败:${res.status}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `创建失败:${res.status}`);
}
}
export async function uploadSourceText(
notebookId: number,
content: string,
title: string,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`删除失败:${res.status}`);
}
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
}
export interface ChunkRead {
@@ -69,7 +109,7 @@ export async function fetchDocumentByChunk(
token: string,
): Promise<DocumentWithChunks> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
headers: { Authorization: `Bearer ${token}` },
@@ -78,12 +118,34 @@ export async function fetchDocumentByChunk(
return res.json() as Promise<DocumentWithChunks>;
}
export async function uploadSourceFiles(
notebookId: number,
files: File[],
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const formData = new FormData();
formData.append("search_space_id", String(notebookId));
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${apiBase}/api/v1/documents/fileupload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function fetchNotebookSources(
notebookId: number,
token: string,
): Promise<SourceItem[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
+9 -2
View File
@@ -12,6 +12,7 @@ export function useStudioNotebooks(): {
notebooks: StudioNotebook[];
isLoading: boolean;
isError: boolean;
token: string | undefined;
} {
const email = getStoredAuth()?.email ?? "";
@@ -35,6 +36,7 @@ export function useStudioNotebooks(): {
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
isError: tokenQuery.isError || notebooksQuery.isError,
token: tokenQuery.data,
};
}
@@ -89,10 +91,15 @@ export function useNotebookChat(
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
// API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) =>
ids
.map((id) => sources.find((s) => s.id === id)?.document?.id)
.filter((id): id is number => id !== undefined);
const sourceIds =
selectedSourceIds.length > 0
? selectedSourceIds
: sources.map((s) => s.id).filter(Boolean);
? toDocIds(selectedSourceIds)
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
+6 -3
View File
@@ -6,9 +6,11 @@ function readEnv(
| "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL"
| "VITE_NOTEBOOK_API_BASE_URL"
| "VITE_AIPI_BACKENP_URL"
| "VITE_AIPI_FRONTEND_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN",
| "VITE_GITHUB_OAUTH_TOKEN"
| "VITE_STUDIO_ENABLED",
): EnvValue {
const value = import.meta.env[key];
return typeof value === "string" && value.trim() ? value : undefined;
@@ -21,7 +23,8 @@ export const env = {
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
};
@@ -11,6 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useModels } from '@/core/models/hooks';
import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
@@ -31,13 +32,14 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
const DEFAULT_MATERIAL_SOURCE: MaterialSource = 'knowledge_base';
const MAX_INTENT_LENGTH = 500;
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
const MATERIAL_SOURCE_OPTIONS: Array<{ value: 'knowledge_base' | 'skill'; label: string }> = [
{ value: 'knowledge_base', label: '知识库' },
{ value: 'skill', label: '技能检索' },
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
@@ -54,10 +56,15 @@ export function AIWritingForm({
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] =
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
const [materialSource, setMaterialSource] = useState<MaterialSource>(DEFAULT_MATERIAL_SOURCE);
const [modelName, setModelName] = useState('');
// 笔记来源:选中的 search_space_id 和多个 document_id
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -109,6 +116,25 @@ export function AIWritingForm({
}
}
function handleNotebookChange(val: string) {
setSelectedNotebookId(val ? Number(val) : null);
setSelectedDocumentIds([]);
}
function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val);
if (val !== 'notebook') {
setSelectedNotebookId(null);
setSelectedDocumentIds([]);
}
}
function toggleDocument(docId: number) {
setSelectedDocumentIds((prev) =>
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId],
);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
@@ -124,6 +150,9 @@ export function AIWritingForm({
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的笔记"才传笔记参数
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -196,7 +225,7 @@ export function AIWritingForm({
<label className={FIELD_LABEL_CLASS}>素材来源</label>
<Select
value={materialSource}
onValueChange={(v) => setMaterialSource(v as 'knowledge_base' | 'skill')}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
@@ -206,13 +235,64 @@ export function AIWritingForm({
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground/80">
知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。
</p>
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="mt-4 space-y-2">
@@ -25,6 +25,8 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { getStoredAuth } from '@/core/auth';
import { fetchStudioToken } from '@/core/studio/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
@@ -739,6 +741,19 @@ export function useAIWriting() {
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
dispatch({ type: 'start', request });
try {
// 如果选择了笔记来源,获取 notebook token 用于后端检索
let notebookToken = '';
if (request.noteSearchSpaceId) {
const email = getStoredAuth()?.email ?? '';
if (email) {
try {
notebookToken = await fetchStudioToken(email);
} catch {
// 忽略 token 获取失败,后端会优雅降级
}
}
}
await stream.submit(
{
user_intent: request.userIntent,
@@ -758,9 +773,16 @@ export function useAIWriting() {
current_draft: null,
review_result: null,
last_user_intervention: null,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? [],
} as Partial<AIWritingValues>,
{
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
config: {
configurable: {
model_name: request.modelName ?? '',
...(notebookToken ? { notebook_token: notebookToken } : {}),
},
} as Record<string, unknown>,
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
},
);
@@ -7,7 +7,7 @@ export interface AIMaterial {
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet' | 'skill';
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
url?: string;
recUuid?: string;
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
@@ -228,8 +228,12 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose';
/** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */
materialSource: 'knowledge_base' | 'skill';
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
materialSource: 'knowledge_base' | 'skill' | 'notebook';
/** 笔记来源(可选):笔记本 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
noteSourceIds?: number[];
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
@@ -1,16 +1,15 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlignLeft,
ArrowLeft,
CloudUpload,
BookOpen,
Files,
Globe,
Loader2,
Plus,
Send,
X,
} from "lucide-react";
@@ -23,7 +22,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api";
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
import { getStoredAuth } from "@/core/auth";
@@ -36,6 +35,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { MessageList } from "@/components/workspace/messages/message-list";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ChatBox } from "@/components/workspace/chats/chat-box";
import { rehypeCitations } from "@/core/rehype";
// ── 来源卡片图标 ────────────────────────────────────────
@@ -101,24 +102,74 @@ function SourceCardSkeleton() {
);
}
const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
// ── 来源 Dialog ─────────────────────────────────────────
function SourcesDialog({
open,
onClose,
notebookName,
notebookId,
token,
sources,
isLoadingSources,
selectedSourceIds,
onToggle,
onUploadSuccess,
}: {
open: boolean;
onClose: () => void;
notebookName: string;
notebookId: number;
token: string;
sources: SourceItem[];
isLoadingSources: boolean;
selectedSourceIds: number[];
onToggle: (id: number) => void;
onUploadSuccess: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [panel, setPanel] = useState<"sources" | "text">("sources");
const [textContent, setTextContent] = useState("");
const [textTitle, setTextTitle] = useState("");
const [submittingText, setSubmittingText] = useState(false);
const [textError, setTextError] = useState("");
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
setUploadError("");
try {
await uploadSourceFiles(notebookId, files, token);
onUploadSuccess();
} catch (err) {
setUploadError(err instanceof Error ? err.message : "上传失败");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleTextSubmit = async () => {
if (!textContent.trim()) return;
setSubmittingText(true);
setTextError("");
try {
await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token);
setTextContent("");
setTextTitle("");
setPanel("sources");
onUploadSuccess();
} catch (err) {
setTextError(err instanceof Error ? err.message : "上传失败");
} finally {
setSubmittingText(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent
@@ -141,40 +192,89 @@ function SourcesDialog({
</DialogHeader>
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* 左侧导航 */}
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
{[
{ icon: Plus, label: "上传文件" },
{ icon: CloudUpload, label: "从云端硬盘添加" },
{ icon: Globe, label: "添加网站" },
{ icon: AlignLeft, label: "复制的文字" },
].map(({ icon: Icon, label }) => (
<button
key={label}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Icon className="size-4 shrink-0" />
<span className="text-left leading-tight">{label}</span>
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
/>
))}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
<input
ref={fileInputRef}
type="file"
multiple
accept={UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => { void handleFileChange(e); }}
/>
<button
disabled={uploading}
onClick={() => { setPanel("sources"); fileInputRef.current?.click(); }}
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
<span className="text-left leading-tight">上传文件</span>
</button>
<button
onClick={() => { setPanel("text"); setTextError(""); }}
className={`flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-muted hover:text-foreground ${panel === "text" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
>
<AlignLeft className="size-4 shrink-0" />
<span className="text-left leading-tight">复制的文字</span>
</button>
{uploadError && (
<p className="px-3 text-xs text-destructive">{uploadError}</p>
)}
</div>
{/* 右侧内容 */}
{panel === "sources" ? (
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-4 gap-3">
{isLoadingSources
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
: sources.map((src) => (
<SourceCard
key={src.id}
item={src}
selected={selectedSourceIds.includes(src.id)}
onToggle={onToggle}
/>
))}
</div>
{!isLoadingSources && sources.length === 0 && (
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
)}
</div>
) : (
<div className="flex flex-1 flex-col gap-3 p-5">
<input
autoFocus
value={textTitle}
onChange={(e) => setTextTitle(e.target.value)}
placeholder="标题(可选)"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
/>
<textarea
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
placeholder="在此粘贴或输入文字内容…"
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
/>
{textError && <p className="text-xs text-destructive">{textError}</p>}
<div className="flex justify-end gap-2">
<button
onClick={() => setPanel("sources")}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
>
取消
</button>
<button
disabled={!textContent.trim() || submittingText}
onClick={() => void handleTextSubmit()}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submittingText && <Loader2 className="size-3.5 animate-spin" />}
添加来源
</button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
@@ -184,16 +284,19 @@ function SourcesDialog({
// ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage() {
const { notebookId } = useParams<{ notebookId: string }>();
const { notebooks, isLoading } = useStudioNotebooks();
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
const { models } = useModels();
const queryClient = useQueryClient();
const [sourcesOpen, setSourcesOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
const [initializedForNotebook, setInitializedForNotebook] = useState<number | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
const [input, setInput] = useState("");
// null = 显示历史线程列表;string = 显示该线程的聊天
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
const [threadsExpanded, setThreadsExpanded] = useState(false);
const THREADS_LIMIT = 5;
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
const [citationLoading, setCitationLoading] = useState(false);
@@ -206,6 +309,13 @@ export default function StudioNotebookDetailPage() {
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
useEffect(() => {
if (sources.length > 0 && initializedForNotebook !== notebook?.id) {
setInitializedForNotebook(notebook?.id ?? null);
setSelectedSourceIds(sources.map((s) => s.id));
}
}, [sources, notebook?.id, initializedForNotebook]);
const citationRehypePlugins = useMemo(() => {
const mapped = sources.map((s) => ({
id: s.id,
@@ -268,6 +378,8 @@ export default function StudioNotebookDetailPage() {
const isChatView = activeThreadId !== null || hasMessages;
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={chatThreadId ?? ""}>
<div className="relative flex h-full flex-col overflow-hidden bg-background">
{/* 背景渐变(列表视图时显示) */}
{!isChatView && (
@@ -275,7 +387,7 @@ export default function StudioNotebookDetailPage() {
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
}}
/>
)}
@@ -299,75 +411,156 @@ export default function StudioNotebookDetailPage() {
</div>
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
</div>
<button
onClick={() => setSourcesOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3" />
<span>{notebook.source_count} 个来源</span>
</button>
<div className="flex items-center gap-2">
<a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
>
<BookOpen className="size-3" />
<span>我的笔记</span>
</a>
<button
onClick={() => setSourcesOpen(true)}
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3" />
<span>{notebook.source_count} 个来源</span>
</button>
</div>
</div>
)}
{/* 列表视图:笔记本标题 + 历史线程列表 */}
{!isChatView && (
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
) : (
<Files className="size-8 text-muted-foreground" />
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
{/* 右上角跳转按钮 */}
<a
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
target="_blank"
rel="noreferrer"
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
>
<BookOpen className="size-3.5" />
<span>我的笔记</span>
</a>
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
<div className="flex size-14 items-center justify-center leading-none select-none">
{notebook.avatar_url ? (
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
) : (
<Files className="size-10 text-foreground/50" />
)}
</div>
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
{notebook.name}
</h1>
<button
onClick={() => setSourcesOpen(true)}
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
>
<Files className="size-3.5" />
<span>{notebook.source_count} 个来源</span>
</button>
{/* 输入框(列表视图内联) */}
<form
onSubmit={(e) => { void handleSend(e); }}
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
>
<button
type="button"
onClick={() => setSourcesOpen(true)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Plus className="size-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</form>
{/* 空状态提示 */}
{(!notebookThreads || notebookThreads.length === 0) && (
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
笔记本对话会显示在这里
</div>
)}
{/* 历史线程列表 */}
{notebookThreads && notebookThreads.length > 0 && (
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
const values = t.values as Record<string, unknown> | undefined;
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
const firstHuman = msgs?.find((m) => m.type === "human");
const rawContent = firstHuman?.content;
const preview =
typeof rawContent === "string"
? rawContent.slice(0, 60)
: Array.isArray(rawContent)
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
: "对话";
const title = (values?.title as string | undefined) || preview || "对话";
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={t.thread_id}
onClick={() => {
setActiveThreadId(t.thread_id);
setThreadId(t.thread_id);
}}
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
>
<span className="truncate text-sm font-medium text-foreground">{title}</span>
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
</button>
);
})}
</div>
{notebookThreads.length > THREADS_LIMIT && (
<button
onClick={() => setThreadsExpanded((v) => !v)}
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
</button>
)}
</div>
)}
</div>
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
{notebook.name}
</h1>
<button
onClick={() => setSourcesOpen(true)}
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
>
<Files className="size-3.5" />
<span>{notebook.source_count} 个来源</span>
</button>
{/* 历史线程列表 */}
{notebookThreads && notebookThreads.length > 0 && (
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
{notebookThreads.map((t) => {
const values = t.values as Record<string, unknown> | undefined;
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
const firstHuman = msgs?.find((m) => m.type === "human");
const rawContent = firstHuman?.content;
const preview =
typeof rawContent === "string"
? rawContent.slice(0, 60)
: Array.isArray(rawContent)
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
: "对话";
const title = (values?.title as string | undefined) || preview || "对话";
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={t.thread_id}
onClick={() => {
setActiveThreadId(t.thread_id);
setThreadId(t.thread_id);
}}
className="flex items-center justify-between rounded-xl bg-muted px-4 py-3 text-left transition-colors hover:bg-muted/70"
>
<span className="truncate text-sm font-medium text-foreground">{title}</span>
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
</button>
);
})}
</div>
)}
</div>
)}
@@ -399,11 +592,11 @@ export default function StudioNotebookDetailPage() {
</div>
)}
{/* 输入框区域 */}
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
{/* 聊天视图的底部输入框 */}
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
<form
onSubmit={(e) => { void handleSend(e); }}
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
>
<button
type="button"
@@ -412,9 +605,17 @@ export default function StudioNotebookDetailPage() {
>
<Plus className="size-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
{models.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -426,24 +627,6 @@ export default function StudioNotebookDetailPage() {
</SelectContent>
</Select>
)}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问 AI..."
disabled={isStreaming}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
>
{isStreaming ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</button>
</form>
</div>
@@ -484,11 +667,18 @@ export default function StudioNotebookDetailPage() {
open={sourcesOpen}
onClose={() => setSourcesOpen(false)}
notebookName={notebook.name}
notebookId={notebook.id}
token={queryClient.getQueryData<string>(["studio", "token", getStoredAuth()?.email ?? ""]) ?? ""}
sources={sources}
isLoadingSources={isLoadingSources}
selectedSourceIds={selectedSourceIds}
onToggle={handleToggleSource}
onUploadSuccess={() => {
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
}}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+209 -53
View File
@@ -2,11 +2,11 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Files, Loader2, Plus } from "lucide-react";
import { Files, Loader2, Plus, MoreVertical, Pencil, Trash2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace } from "@/core/studio/api";
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
import type { StudioNotebook } from "@/core/studio/types";
import {
@@ -15,81 +15,175 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
function NotebookCardSkeleton() {
return (
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
<div className="size-10 animate-pulse rounded-xl bg-muted" />
<div className="flex flex-col gap-1.5">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
<div className="mt-auto flex flex-col gap-1.5 pt-8">
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
</div>
</div>
);
}
function NotebookCard({ nb }: { nb: StudioNotebook }) {
function NotebookCard({
nb,
onRename,
onDelete,
}: {
nb: StudioNotebook;
onRename: (nb: StudioNotebook) => void;
onDelete: (nb: StudioNotebook) => void;
}) {
return (
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
>
<div className="flex items-start justify-between">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
{nb.avatar_url ? (
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
) : (
<Files className="size-5 text-muted-foreground" />
)}
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
<div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
{nb.avatar_url ? (
<img src={nb.avatar_url} alt="" className="size-8 rounded object-contain" />
) : (
<Files className="size-7 text-muted-foreground" />
)}
</div>
{/* spacer so title stays below icon */}
<div className="size-7" />
</div>
<div className="mt-auto flex flex-col gap-1 pt-8">
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
{nb.name}
</span>
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
</div>
</Link>
{/* three-dot menu — absolute so it doesn't shift layout */}
<div className="absolute right-4 top-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex size-7 items-center justify-center rounded-full text-[#888] opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/10"
onClick={(e) => e.preventDefault()}
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => onRename(nb)}>
<Pencil className="mr-2 size-4" />
重命名
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(nb)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col gap-0.5">
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
{nb.name}
</span>
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
</div>
</Link>
</div>
);
}
export default function StudioNotebooksPage() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
const trimmed = name.trim();
// ── 新建 ──
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
// ── 重命名 ──
const [renameTarget, setRenameTarget] = useState<StudioNotebook | null>(null);
const [renameName, setRenameName] = useState("");
const [renaming, setRenaming] = useState(false);
const [renameError, setRenameError] = useState("");
// ── 删除 ──
const [deleteTarget, setDeleteTarget] = useState<StudioNotebook | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState("");
function getToken() {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
return token;
}
async function handleCreate() {
const trimmed = createName.trim();
if (!trimmed) return;
setCreating(true);
setError("");
setCreateError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setDialogOpen(false);
setName("");
// 刷新笔记本列表
await createSearchSpace(trimmed, getToken());
setCreateOpen(false);
setCreateName("");
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
setCreateError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
}
async function handleRename() {
if (!renameTarget) return;
const trimmed = renameName.trim();
if (!trimmed) return;
setRenaming(true);
setRenameError("");
try {
await renameSearchSpace(renameTarget.id, trimmed, getToken());
setRenameTarget(null);
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setRenameError(e instanceof Error ? e.message : "重命名失败");
} finally {
setRenaming(false);
}
}
async function handleDelete() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError("");
try {
await deleteSearchSpace(deleteTarget.id, getToken());
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setDeleteError(e instanceof Error ? e.message : "删除失败");
} finally {
setDeleting(false);
}
}
return (
<div className="min-h-full bg-background p-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
<button
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="size-4" />
新建笔记本
@@ -100,13 +194,22 @@ export default function StudioNotebooksPage() {
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
)}
{/* Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
: notebooks.map((nb) => (
<NotebookCard
key={nb.id}
nb={nb}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
/>
))}
</div>
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
{/* ── 新建 Dialog ── */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
@@ -114,25 +217,78 @@ export default function StudioNotebooksPage() {
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{createError && <p className="text-xs text-destructive">{createError}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
<Button variant="outline" size="sm" disabled={creating} onClick={() => setCreateOpen(false)}>取消</Button>
<Button size="sm" disabled={!createName.trim() || creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* ── 重命名 Dialog ── */}
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>重命名笔记本</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<div className="flex items-center gap-3">
<div className="flex size-12 flex-shrink-0 items-center justify-center text-3xl">
{renameTarget?.avatar_url
? <img src={renameTarget.avatar_url} alt="" className="size-10 rounded object-contain" />
: <Files className="size-8 text-muted-foreground" />}
</div>
<input
autoFocus
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleRename(); }}
disabled={renaming}
className="flex-1 rounded-lg border-2 border-foreground bg-background px-3 py-2 text-sm outline-none disabled:opacity-50"
/>
</div>
{renameError && <p className="text-xs text-destructive">{renameError}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={renaming} onClick={() => setRenameTarget(null)}>取消</Button>
<Button size="sm" disabled={!renameName.trim() || renaming} onClick={() => void handleRename()}>
{renaming ? <Loader2 className="size-4 animate-spin" /> : "保存"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* ── 删除确认 Dialog ── */}
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>删除笔记本</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-1">
<p className="text-sm text-muted-foreground">
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
删除后笔记本内所有来源数据将一并移除,且无法恢复。
</p>
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={deleting} onClick={() => setDeleteTarget(null)}>取消</Button>
<Button variant="destructive" size="sm" disabled={deleting} onClick={() => void handleDelete()}>
{deleting ? <Loader2 className="size-4 animate-spin" /> : "删除"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+13 -17
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
@@ -43,6 +43,11 @@ import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanni
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
function NotebookDetailPageWithKey() {
const { notebookId } = useParams<{ notebookId: string }>();
return <StudioNotebookDetailPage key={notebookId} />;
}
function isInIframe(): boolean {
try {
return window.self !== window.top;
@@ -73,12 +78,7 @@ export function WorkspaceLayout({
const pageSidebarWidth = pageSidebarHidden
? '0rem'
: pageSidebarDark
? // dark page sidebar:
// 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px
// 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边;
// 左下角浮动按钮是 fixed,叠在主内容上不影响 layout
// 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致
pageSidebarCollapsed
? pageSidebarCollapsed
? '0rem'
: '118px'
: pageSidebarCollapsed
@@ -87,15 +87,6 @@ export function WorkspaceLayout({
return (
<SidebarProvider
// 关键:把 workspace 侧栏从「锚定视口(fixed)」改成「锚定本布局容器(absolute)」。
// 原 shadcn 原语用 fixed + left:var(--page-sidebar-width) + top:var(--header-height)
// 把侧栏钉在视口坐标上 —— 在无界等「DOM 挂主文档、容器不占满整屏」的嵌套场景下,
// fixed 会逃逸到整屏左侧、高度锁死为视口高度(即超出父元素 / 高度写死的根因)。
//
// 改 absolute 后侧栏改以本 wrapper(relative)为定位基准;又因外层 main 已经
// pl-[--page-sidebar-width] 让开了页面侧栏、且 main 的 pl 恒等于 --page-sidebar-width,
// 故这里 left/top 归零即与原 fixed 视觉位置逐像素一致;高度则跟随容器而非视口。
// 作用域仅限本 SidebarProvider 实例(不动全局 ui/sidebar 原语),不影响 artifacts 等其它侧栏。
className={[
'relative h-full w-full overflow-x-hidden',
'[&_[data-slot=sidebar-container]]:absolute',
@@ -303,7 +294,7 @@ export default function WorkspaceRoutes({
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
<NotebookDetailPageWithKey />
</ChatRuntime>
</WorkspaceLayout>
}
@@ -470,3 +461,8 @@ export default function WorkspaceRoutes({
</RouteBaseProvider>
);
}
// Suppress unused import warnings for pages referenced only in some branches
void UsersPage;
void AdminUserActivityPage;
void AdminMemoryPage;
@@ -1,8 +1,9 @@
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import time
import uuid
@@ -21,6 +22,10 @@ from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
_DEFAULT_MAX_MATERIALS = 50
_MIN_MAX_MATERIALS = 1
@@ -95,6 +100,67 @@ def _notice(title: str, detail: str, raw: str = "") -> dict:
return event
async def _search_notebook(
keywords: list[str],
search_space_id: int,
source_ids: list[int],
token: str,
) -> list[Material]:
"""从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。"""
import httpx # 懒导入:仅在笔记模式下才需要
base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
if not base:
logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search")
return []
results: list[Material] = []
mat_idx = 0
try:
async with httpx.AsyncClient(timeout=20) as client:
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
try:
resp = await client.post(
f"{base}{_NOTEBOOK_SEARCH_PATH}",
headers={"Authorization": f"Bearer {token}"},
json={
"query": kw,
"source_ids": source_ids,
"search_space_id": search_space_id,
"top_k": _NOTEBOOK_TOP_K,
},
)
resp.raise_for_status()
data = resp.json()
# 解析 documents 列表,每个 chunk 作为独立素材
documents: list[dict] = data.get("documents") if isinstance(data, dict) else []
for doc in documents or []:
doc_title: str = doc.get("title") or "笔记文档"
doc_url: str = doc.get("url") or ""
for chunk in doc.get("chunks") or []:
chunk_content: str = (chunk.get("content") or "").strip()
if not chunk_content:
continue
mat_idx += 1
raw_score = chunk.get("score")
chunk_score = float(raw_score) if raw_score is not None else max(0.5, 0.95 - (mat_idx - 1) * 0.03)
results.append(Material(
id=f"nb{mat_idx:03d}",
title=doc_title,
content=chunk_content[:500],
source=f"笔记·{doc_title}",
source_type="notebook",
relevance_score=chunk_score,
url=doc_url,
))
except Exception as e:
logger.warning("notebook search kw=%r failed: %s", kw, e)
except Exception as e:
logger.warning("notebook search client error: %s", e)
return results
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
intent = state["user_intent"]
article_type = state.get("article_type", "analysis")
@@ -181,6 +247,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
notebook_materials: list[Material] = []
if notebook_search_space_id:
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
notebook_token: str = cfg.get("notebook_token") or ""
_emit("正在检索笔记内容…")
notebook_materials = await _search_notebook(
keywords[:_NOTEBOOK_MAX_KEYWORDS],
notebook_search_space_id,
notebook_source_ids,
notebook_token,
)
if notebook_materials:
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
# Step 2: 搜索
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
@@ -330,10 +412,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
existing_pkg = state.get("material_package")
if is_skill_search:
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
# 笔记素材追加在技能素材之后
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
raw_materials.extend(notebook_materials)
else:
seen_urls: set[str] = set()
raw_materials = []
# 笔记素材优先排在最前面,赋予高相关度
raw_materials = list(notebook_materials)
for m in notebook_materials:
if m.get("url"):
seen_urls.add(m["url"])
preserve_existing = existing_pkg and (is_supplement or is_append_only)
if preserve_existing:
for m in existing_pkg.get("materials", []):
@@ -140,6 +140,9 @@ class AIWritingState(TypedDict):
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
# model_name,存进 state 才能让 resume 后才运行的节点拿到用户模型。
model_name: str
# 笔记来源(可选):传入后 researcher 阶段会额外从笔记检索素材
notebook_search_space_id: Optional[int]
notebook_source_ids: Optional[List[int]]
# Agent A 输出
material_package: Optional[MaterialPackage]
@@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str:
headers={"Authorization": f"Bearer {token}"},
json={
"query": query,
"source_ids": [source_ids],
"source_ids": source_ids,
"search_space_id": search_space_id,
"top_k": top_k,
},