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

@@ -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[];
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────