feat(ai-writing): 大纲先行确认 + 新文章类型识别 + 参考文献 + 编辑器修复
- 写作配置 Q&A 改为「先流式生成大纲并多轮确认 → 再确认意图」,放宽轮数上限 - 意图识别出列表外的新文章类型时带上类型名+简介(仅本次写作临时使用) - 成稿支持参考文献:正文 [n] 角标 + 文末按出现顺序的「参考文献」列表(表单开关,默认开) - 流式接取未闭合 <think> 思考内容,首 token 前加「正在整理…」占位 - 修复右侧编辑器输入空格只出一次、光标跳字(避免自激回灌 setContent 重置光标) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cd33aaeded
commit
b8c78ba06e
26 files changed
+1273
-352
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
groupMessages,
|
||||
hasContent,
|
||||
hasPresentFiles,
|
||||
hasReasoning,
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
hasReasoning,
|
||||
hasToolCalls,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -103,6 +103,7 @@ export function MessageList({
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
@@ -120,6 +121,8 @@ export function MessageList({
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
@@ -152,10 +155,10 @@ export function MessageList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allMessages = useMemo(
|
||||
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
||||
[thread.messages],
|
||||
);
|
||||
const allMessages = useMemo(
|
||||
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
|
||||
[thread.messages],
|
||||
);
|
||||
const messages = useMemo(
|
||||
() =>
|
||||
hiddenMessageIds.size === 0
|
||||
@@ -430,6 +433,7 @@ export function MessageList({
|
||||
message={message}
|
||||
onApprove={onSetupWritingApprove}
|
||||
onReject={onSetupWritingReject}
|
||||
onStart={onSetupWritingStart}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -629,6 +633,8 @@ export type SetupAgentApprovalPayload = {
|
||||
export type SetupWritingApprovalPayload = {
|
||||
user_intent?: string;
|
||||
article_type?: string;
|
||||
/** 当 article_type 是已有类型库里没有的新类型时,模型识别出的一句话特征说明(命中已有类型时为空)。 */
|
||||
article_type_detail?: string;
|
||||
word_count_target?: number | null;
|
||||
target_audience?: string;
|
||||
writing_mode?: string;
|
||||
@@ -820,14 +826,16 @@ function SetupWritingApprovalCard({
|
||||
message,
|
||||
onApprove,
|
||||
onReject,
|
||||
onStart,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
message: unknown;
|
||||
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getSetupWritingApprovalPayload(message);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | "start" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -838,7 +846,7 @@ function SetupWritingApprovalCard({
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
async function run(action: "approve" | "reject" | "start") {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
@@ -846,6 +854,8 @@ function SetupWritingApprovalCard({
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await onApprove?.(approvalPayload);
|
||||
} else if (action === "start") {
|
||||
await onStart?.(approvalPayload);
|
||||
} else {
|
||||
await onReject?.(approvalPayload);
|
||||
}
|
||||
@@ -854,9 +864,14 @@ function SetupWritingApprovalCard({
|
||||
}
|
||||
}
|
||||
|
||||
const hasNewType = !!approvalPayload.article_type_detail?.trim();
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
if (approvalPayload.article_type) {
|
||||
rows.push({ label: "文章类型", value: approvalPayload.article_type });
|
||||
rows.push({
|
||||
label: "文章类型",
|
||||
// 新类型(列表外)标注「· 新」,让用户知道这是模型新识别的类型。
|
||||
value: hasNewType ? `${approvalPayload.article_type} · 新` : approvalPayload.article_type,
|
||||
});
|
||||
}
|
||||
if (typeof approvalPayload.word_count_target === "number") {
|
||||
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
|
||||
@@ -903,6 +918,13 @@ function SetupWritingApprovalCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasNewType ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">新类型说明</div>
|
||||
<p className="text-sm leading-6">{approvalPayload.article_type_detail}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.outline ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">已有大纲</div>
|
||||
@@ -926,6 +948,7 @@ function SetupWritingApprovalCard({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={onStart ? "outline" : "default"}
|
||||
size="sm"
|
||||
disabled={busy || !onApprove}
|
||||
onClick={() => void run("approve")}
|
||||
@@ -933,6 +956,17 @@ function SetupWritingApprovalCard({
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
应用到表单
|
||||
</Button>
|
||||
{onStart ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void run("start")}
|
||||
>
|
||||
<PenLineIcon className="h-4 w-4" />
|
||||
直接开始写作
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -212,21 +212,33 @@ export function extractToolResultPayload(
|
||||
}
|
||||
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
const THINK_OPEN = "<think>";
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
const reasoningParts: string[] = [];
|
||||
const cleaned = content
|
||||
.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
// 1) 先抽出所有**已闭合**的 <think>...</think>。
|
||||
let working = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// 2) 流式中常见**未闭合**的 <think>(还没等到 </think> 就先到达):把开标签之后的内容
|
||||
// 当作「实时思考」抽到 reasoning、从答案正文里移除。否则模型慢/先思考时,用户既会看到
|
||||
// 字面 "<think>",又会因答案区长时间空白而以为卡死。这样思考内容会实时流进思考区。
|
||||
const openIdx = working.indexOf(THINK_OPEN);
|
||||
if (openIdx !== -1 && !working.slice(openIdx).includes("</think>")) {
|
||||
const partial = working.slice(openIdx + THINK_OPEN.length).trim();
|
||||
if (partial) {
|
||||
reasoningParts.push(partial);
|
||||
}
|
||||
working = working.slice(0, openIdx);
|
||||
}
|
||||
|
||||
return {
|
||||
content: cleaned,
|
||||
content: working.trim(),
|
||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export function AIWritingForm({
|
||||
audience, setAudience,
|
||||
wordCountInput, setWordCountInput,
|
||||
strictMode, setStrictMode,
|
||||
addReferences, setAddReferences,
|
||||
materialSource, setMaterialSource,
|
||||
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
|
||||
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
|
||||
@@ -164,6 +165,7 @@ export function AIWritingForm({
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
addReferences,
|
||||
materialSource: materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
// 只有选了"我的空间"才传笔记参数
|
||||
@@ -381,6 +383,23 @@ export function AIWritingForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="add-references"
|
||||
checked={addReferences}
|
||||
onChange={(e) => setAddReferences(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
|
||||
/>
|
||||
<label htmlFor="add-references" className="cursor-pointer select-none">
|
||||
<span className="text-sm font-medium text-foreground/90">添加参考文献</span>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
|
||||
开启后,正文引用素材处自动加角标 [n],文末按出现顺序生成「参考文献」列表(像论文一样)。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
|
||||
*/
|
||||
const ENABLE_PAUSE_RESUME_UI = false;
|
||||
import { countArticleWords } from '../../utils/wordCount';
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
@@ -482,6 +483,8 @@ export function AIWritingPanel({
|
||||
outline={state.outline}
|
||||
reviewResult={state.reviewResult}
|
||||
draftTitle={state.currentDraftTitle}
|
||||
draftWordCount={countArticleWords(state.currentDraftMarkdown)}
|
||||
wordCountTarget={state.request?.wordCountTarget}
|
||||
revisionCount={state.revisionCount}
|
||||
skillSearchSteps={state.skillSearchSteps}
|
||||
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
|
||||
|
||||
@@ -24,6 +24,10 @@ interface InterventionCardProps {
|
||||
outline: AIOutline | null;
|
||||
reviewResult: ReviewResult | null;
|
||||
draftTitle: string;
|
||||
/** 当前草稿实际字数(与目标对比展示,让用户在确认时即看到字数是否达标) */
|
||||
draftWordCount?: number;
|
||||
/** 用户设定的目标字数 */
|
||||
wordCountTarget?: number;
|
||||
revisionCount: number;
|
||||
skillSearchSteps?: SkillSearchStep[];
|
||||
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
|
||||
@@ -34,7 +38,7 @@ interface InterventionCardProps {
|
||||
|
||||
export function InterventionCard({
|
||||
pauseEvent, materials, outline, reviewResult,
|
||||
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
|
||||
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
|
||||
}: InterventionCardProps) {
|
||||
const pausePoint = pauseEvent.pausePoint;
|
||||
|
||||
@@ -64,6 +68,8 @@ export function InterventionCard({
|
||||
return (
|
||||
<DraftConfirmCard
|
||||
draftTitle={draftTitle}
|
||||
draftWordCount={draftWordCount}
|
||||
wordCountTarget={wordCountTarget}
|
||||
revisionCount={revisionCount}
|
||||
reviewResult={reviewResult}
|
||||
onSubmit={onSubmit}
|
||||
@@ -357,13 +363,15 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
|
||||
|
||||
interface DraftConfirmCardProps {
|
||||
draftTitle: string;
|
||||
draftWordCount?: number;
|
||||
wordCountTarget?: number;
|
||||
revisionCount: number;
|
||||
reviewResult: ReviewResult | null;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
|
||||
function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -399,6 +407,11 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
|
||||
|
||||
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
|
||||
|
||||
// 实际字数 vs 目标字数对比:让用户在确认时直接看到是否达标,偏差超 100 字标橙提醒
|
||||
const showWordCmp = wordCountTarget != null && wordCountTarget > 0 && draftWordCount != null && draftWordCount > 0;
|
||||
const wordDiff = showWordCmp ? draftWordCount! - wordCountTarget! : 0;
|
||||
const wordWithin = Math.abs(wordDiff) <= 100;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -410,6 +423,12 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
|
||||
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
|
||||
· 右侧可预览完整内容
|
||||
</p>
|
||||
{showWordCmp && (
|
||||
<p className={cn('text-xs mt-0.5', wordWithin ? 'text-muted-foreground' : 'text-orange-600 dark:text-orange-400')}>
|
||||
实际 {draftWordCount} 字 / 目标 {wordCountTarget} 字
|
||||
{wordDiff !== 0 && `(${wordDiff > 0 ? '超出' : '不足'} ${Math.abs(wordDiff)} 字${wordWithin ? ',在误差范围内' : ''})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { ListTree, Plus } from 'lucide-react';
|
||||
import { ListTree, Plus, GripVertical } from 'lucide-react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AIOutline, KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { normalizeKeyPoint } from '../../types/ai-writing';
|
||||
import { OutlineSection } from './OutlineSection';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
|
||||
@@ -11,15 +25,177 @@ interface OutlineCardProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
// 节点路径(章节/小节/子要点)
|
||||
export type DragPath =
|
||||
| { type: 'section'; s: number }
|
||||
| { type: 'keypoint'; s: number; k: number }
|
||||
| { type: 'subpoint'; s: number; k: number; p: number };
|
||||
|
||||
// 节点 id:s{S} / s{S}-k{K} / s{S}-k{K}-p{P}
|
||||
export function parseItemId(id: string): DragPath | null {
|
||||
const m = id.match(/^s(\d+)(?:-k(\d+))?(?:-p(\d+))?$/);
|
||||
if (!m) return null;
|
||||
const s = parseInt(m[1], 10);
|
||||
const k = m[2] !== undefined ? parseInt(m[2], 10) : undefined;
|
||||
const p = m[3] !== undefined ? parseInt(m[3], 10) : undefined;
|
||||
if (p !== undefined && k !== undefined) return { type: 'subpoint', s, k, p };
|
||||
if (k !== undefined) return { type: 'keypoint', s, k };
|
||||
return { type: 'section', s };
|
||||
}
|
||||
|
||||
// 区域 id:before:<节点id> / after:<节点id> / into:<节点id>
|
||||
type Zone = 'before' | 'after' | 'into';
|
||||
function parseZoneId(zoneId: string): { zone: Zone; targetPath: DragPath; targetItemId: string } | null {
|
||||
const m = zoneId.match(/^(before|after|into):(.+)$/);
|
||||
if (!m) return null;
|
||||
const targetItemId = m[2];
|
||||
const targetPath = parseItemId(targetItemId);
|
||||
if (!targetPath) return null;
|
||||
return { zone: m[1] as Zone, targetPath, targetItemId };
|
||||
}
|
||||
|
||||
function cloneOutline(outline: AIOutline): AIOutline {
|
||||
return {
|
||||
...outline,
|
||||
sections: outline.sections.map((s) => ({
|
||||
...s,
|
||||
keyPoints: (s.keyPoints ?? []).map((kp) => {
|
||||
const n = normalizeKeyPoint(kp);
|
||||
return { ...n, subPoints: [...n.subPoints] } as KeyPoint;
|
||||
}),
|
||||
materialIds: [...(s.materialIds ?? [])],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type SourceValue = OutlineSectionType | KeyPoint | string;
|
||||
|
||||
// 拖到自身子树内非法(章节扔进自己的小节/子要点 / 小节扔进自己的子要点)
|
||||
function isDescendantTarget(from: DragPath, to: DragPath): boolean {
|
||||
if (from.type === 'section') return to.s === from.s;
|
||||
if (from.type === 'keypoint') return to.type !== 'section' && to.s === from.s && to.k === from.k;
|
||||
return false;
|
||||
}
|
||||
|
||||
function readAt(outline: AIOutline, p: DragPath): SourceValue {
|
||||
if (p.type === 'section') return outline.sections[p.s];
|
||||
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
|
||||
if (p.type === 'keypoint') return kps[p.k];
|
||||
return kps[p.k].subPoints[p.p];
|
||||
}
|
||||
|
||||
function removeAt(outline: AIOutline, p: DragPath): void {
|
||||
if (p.type === 'section') { outline.sections.splice(p.s, 1); return; }
|
||||
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
|
||||
if (p.type === 'keypoint') { kps.splice(p.k, 1); return; }
|
||||
kps[p.k].subPoints.splice(p.p, 1);
|
||||
}
|
||||
|
||||
function insertAt(outline: AIOutline, p: DragPath, value: SourceValue): void {
|
||||
if (p.type === 'section') { outline.sections.splice(p.s, 0, value as OutlineSectionType); return; }
|
||||
const kps = outline.sections[p.s].keyPoints as KeyPoint[];
|
||||
if (p.type === 'keypoint') { kps.splice(p.k, 0, value as KeyPoint); return; }
|
||||
kps[p.k].subPoints.splice(p.p, 0, value as string);
|
||||
}
|
||||
|
||||
// 删源后,同一父列表里 target index 会前移一位
|
||||
function adjustTargetAfterRemove(to: DragPath, from: DragPath): DragPath {
|
||||
if (from.type === 'section') {
|
||||
if (to.s > from.s) return { ...to, s: to.s - 1 } as DragPath;
|
||||
return to;
|
||||
}
|
||||
if (from.type === 'keypoint') {
|
||||
if (to.type !== 'section' && to.s === from.s && to.k > from.k) {
|
||||
return { ...to, k: to.k - 1 } as DragPath;
|
||||
}
|
||||
return to;
|
||||
}
|
||||
if (to.type === 'subpoint' && to.s === from.s && to.k === from.k && to.p > from.p) {
|
||||
return { ...to, p: to.p - 1 };
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
type Level = DragPath['type'];
|
||||
|
||||
function convertToLevel(value: SourceValue, fromLevel: Level, toLevel: Level): SourceValue {
|
||||
if (fromLevel === toLevel) return value;
|
||||
if (toLevel === 'section') {
|
||||
if (fromLevel === 'keypoint') {
|
||||
const kp = value as KeyPoint;
|
||||
return {
|
||||
sectionTitle: kp.pointTitle,
|
||||
keyPoints: kp.subPoints.map((sp) => ({ pointTitle: sp, subPoints: [] })),
|
||||
materialIds: [],
|
||||
};
|
||||
}
|
||||
return { sectionTitle: value as string, keyPoints: [], materialIds: [] };
|
||||
}
|
||||
if (toLevel === 'keypoint') {
|
||||
if (fromLevel === 'section') {
|
||||
const sec = value as OutlineSectionType;
|
||||
const sub = (sec.keyPoints ?? [])
|
||||
.map((kp) => normalizeKeyPoint(kp).pointTitle)
|
||||
.filter((s) => !!s);
|
||||
return { pointTitle: sec.sectionTitle, subPoints: sub };
|
||||
}
|
||||
return { pointTitle: value as string, subPoints: [] };
|
||||
}
|
||||
if (fromLevel === 'section') return (value as OutlineSectionType).sectionTitle;
|
||||
return (value as KeyPoint).pointTitle;
|
||||
}
|
||||
|
||||
// into 区域:作为 target 节点的「末尾子项」插入
|
||||
function childPathOf(outline: AIOutline, target: DragPath): DragPath | null {
|
||||
if (target.type === 'section') {
|
||||
const kps = outline.sections[target.s]?.keyPoints ?? [];
|
||||
return { type: 'keypoint', s: target.s, k: kps.length };
|
||||
}
|
||||
if (target.type === 'keypoint') {
|
||||
const kp = normalizeKeyPoint((outline.sections[target.s]?.keyPoints ?? [])[target.k]);
|
||||
return { type: 'subpoint', s: target.s, k: target.k, p: kp.subPoints.length };
|
||||
}
|
||||
return null; // 子要点没有子节点
|
||||
}
|
||||
|
||||
// before/after:作为 target 节点的兄弟插入
|
||||
function siblingPathOf(target: DragPath, side: 'before' | 'after'): DragPath {
|
||||
const delta = side === 'after' ? 1 : 0;
|
||||
if (target.type === 'section') return { type: 'section', s: target.s + delta };
|
||||
if (target.type === 'keypoint') return { type: 'keypoint', s: target.s, k: target.k + delta };
|
||||
return { type: 'subpoint', s: target.s, k: target.k, p: target.p + delta };
|
||||
}
|
||||
|
||||
// 自定义碰撞:pointerWithin 命中所有包含光标的 droppable,取 rect 最小的(最内层)
|
||||
// —— 这是嵌套区域 (section > keypoint > subpoint,每层又分 before/into/after) 能
|
||||
// 正确选中「最深的那个」的关键。
|
||||
function getRectArea(rect: { width: number; height: number } | null | undefined): number {
|
||||
if (!rect) return Infinity;
|
||||
return rect.width * rect.height;
|
||||
}
|
||||
|
||||
const innermostCollision: CollisionDetection = (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length === 0) return rectIntersection(args);
|
||||
let best = pointer[0];
|
||||
let bestArea = getRectArea(args.droppableRects.get(best.id));
|
||||
for (const c of pointer.slice(1)) {
|
||||
const a = getRectArea(args.droppableRects.get(c.id));
|
||||
if (a < bestArea) { best = c; bestArea = a; }
|
||||
}
|
||||
return [best];
|
||||
};
|
||||
|
||||
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(outline.title);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const editable = !readOnly && !!onChange;
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
@@ -40,14 +216,6 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
|
||||
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function reorderSections(from: number, to: number) {
|
||||
if (from === to) return;
|
||||
const sections = [...outline.sections];
|
||||
const [moved] = sections.splice(from, 1);
|
||||
sections.splice(to, 0, moved);
|
||||
onChange?.({ ...outline, sections });
|
||||
}
|
||||
|
||||
function addSection() {
|
||||
onChange?.({
|
||||
...outline,
|
||||
@@ -55,9 +223,66 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(String(event.active.id));
|
||||
}
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || !editable) return;
|
||||
|
||||
const fromId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
|
||||
const from = parseItemId(fromId);
|
||||
const zoneInfo = parseZoneId(overId);
|
||||
if (!from || !zoneInfo) return;
|
||||
const { zone, targetPath, targetItemId } = zoneInfo;
|
||||
|
||||
// 落在源自身的任何区域 → 无操作
|
||||
if (targetItemId === fromId) return;
|
||||
|
||||
// 计算插入路径:into = 末尾子项;before/after = 同级兄弟
|
||||
const insertPath = zone === 'into'
|
||||
? childPathOf(outline, targetPath)
|
||||
: siblingPathOf(targetPath, zone);
|
||||
if (!insertPath) return;
|
||||
|
||||
// 拖入源自身子树 → 非法
|
||||
if (isDescendantTarget(from, insertPath)) return;
|
||||
|
||||
const next = cloneOutline(outline);
|
||||
const sourceValue = readAt(next, from);
|
||||
removeAt(next, from);
|
||||
const adjusted = adjustTargetAfterRemove(insertPath, from);
|
||||
const converted = convertToLevel(sourceValue, from.type, adjusted.type);
|
||||
insertAt(next, adjusted, converted);
|
||||
|
||||
onChange?.(next);
|
||||
}
|
||||
|
||||
function renderDragOverlay() {
|
||||
if (!activeId) return null;
|
||||
const path = parseItemId(activeId);
|
||||
if (!path) return null;
|
||||
const section = outline.sections[path.s];
|
||||
if (!section) return null;
|
||||
if (path.type === 'section') {
|
||||
return <OverlayCard level="section" label={section.sectionTitle} />;
|
||||
}
|
||||
const kp = normalizeKeyPoint((section.keyPoints ?? [])[path.k]);
|
||||
if (path.type === 'keypoint') {
|
||||
return <OverlayCard level="keypoint" label={kp.pointTitle} />;
|
||||
}
|
||||
return <OverlayCard level="subpoint" label={kp.subPoints?.[path.p] ?? ''} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="text-xs font-medium flex-1">文章大纲</span>
|
||||
@@ -97,34 +322,30 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 章节列表 */}
|
||||
<div className="space-y-1.5">
|
||||
{outline.sections.map((section, i) => (
|
||||
<OutlineSection
|
||||
key={i}
|
||||
section={section}
|
||||
index={i}
|
||||
onChange={(updated) => updateSection(i, updated)}
|
||||
onRemove={editable ? () => removeSection(i) : undefined}
|
||||
readOnly={!editable}
|
||||
isDragging={dragIndex === i}
|
||||
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
|
||||
onDragStart={() => setDragIndex(i)}
|
||||
onDragEnter={() => setOverIndex(i)}
|
||||
onDrop={() => {
|
||||
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 章节列表 —— 三区域拖放:顶/底边重排,中间嵌入为子项 */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={innermostCollision}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
{outline.sections.map((section, i) => (
|
||||
<OutlineSection
|
||||
key={i}
|
||||
section={section}
|
||||
index={i}
|
||||
sectionIndex={i}
|
||||
onChange={(updated) => updateSection(i, updated)}
|
||||
onRemove={editable ? () => removeSection(i) : undefined}
|
||||
readOnly={!editable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null}>{renderDragOverlay()}</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* 添加章节 */}
|
||||
{editable && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -142,3 +363,23 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 拖动时跟随光标的浮动预览。DragOverlay 把这个节点 portal 到 body,
|
||||
// 不受任何祖先 overflow:hidden 影响,所以拖出卡片也始终可见。
|
||||
function OverlayCard({ level, label }: { level: Level; label: string }) {
|
||||
const styleByLevel: Record<Level, string> = {
|
||||
section: 'rounded border-2 border-blue-400 bg-background text-xs font-medium px-2.5 py-2 shadow-lg',
|
||||
keypoint: 'rounded border-2 border-blue-400 bg-muted/40 text-xs px-2 py-1.5 shadow-lg',
|
||||
subpoint: 'rounded border-2 border-blue-400 bg-background text-xs px-2 py-1 shadow-lg',
|
||||
};
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5 cursor-grabbing min-w-[180px] max-w-[420px]', styleByLevel[level])}>
|
||||
<GripVertical className="size-3 text-muted-foreground/50 shrink-0" />
|
||||
{level === 'keypoint' && <span className="text-purple-500/70 shrink-0">###</span>}
|
||||
{level === 'subpoint' && <span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>}
|
||||
<span className="truncate">
|
||||
{label || <span className="italic text-muted-foreground">(未命名)</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { normalizeKeyPoint } from '../../types/ai-writing';
|
||||
@@ -8,36 +9,34 @@ import { CollapseTransition } from './CollapseTransition';
|
||||
interface OutlineSectionProps {
|
||||
section: OutlineSectionType;
|
||||
index: number;
|
||||
/** 章节在 outline.sections 中的下标,用于生成稳定 dnd id */
|
||||
sectionIndex: number;
|
||||
onChange: (updated: OutlineSectionType) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
// Drag-and-drop (章节级别)
|
||||
isDragging?: boolean;
|
||||
isOver?: boolean;
|
||||
onDragStart?: () => void;
|
||||
onDragEnter?: () => void;
|
||||
onDrop?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
|
||||
* subPoints[] 为 bullet 列表(Level 3)。
|
||||
*
|
||||
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
|
||||
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
|
||||
* 编辑过的旧 session 大纲会被持久化为新格式)。
|
||||
*/
|
||||
// 各级 grip 标题提示文案
|
||||
const DRAG_HINT = '按住拖动:项的顶/底边 = 同级重排;中间区域 = 嵌入为子项';
|
||||
const DRAG_HINT_LEAF = '按住拖动重排(顶半区 = 上插入,底半区 = 下插入)';
|
||||
|
||||
// ─── 章节 (Level 1) ───────────────────────────────────────────────────────────
|
||||
export function OutlineSection({
|
||||
section, index, onChange, onRemove, readOnly,
|
||||
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
|
||||
section, index, sectionIndex, onChange, onRemove, readOnly,
|
||||
}: OutlineSectionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
|
||||
|
||||
// 把 keyPoints 统一成 KeyPoint[];旧 string 元素会被 lift。useMemo 避免每次 render
|
||||
// 都生成新引用导致子组件无谓重渲染。
|
||||
const id = `s${sectionIndex}`;
|
||||
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
|
||||
id, disabled: readOnly,
|
||||
});
|
||||
// 三个 drop 区:before / into / after
|
||||
const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly });
|
||||
const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly });
|
||||
const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly });
|
||||
|
||||
const keyPoints: KeyPoint[] = useMemo(
|
||||
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
|
||||
[section.keyPoints],
|
||||
@@ -52,128 +51,153 @@ export function OutlineSection({
|
||||
setTitleDraft(section.sectionTitle);
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyPoint(i: number, updated: KeyPoint) {
|
||||
const next = [...keyPoints];
|
||||
next[i] = updated;
|
||||
onChange({ ...section, keyPoints: next });
|
||||
}
|
||||
|
||||
function removeKeyPoint(i: number) {
|
||||
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addKeyPoint() {
|
||||
onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
|
||||
}
|
||||
|
||||
// 子要点总数,显示在章节头给个粗略概览
|
||||
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!readOnly}
|
||||
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
|
||||
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
|
||||
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
|
||||
ref={setDragRef}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
'rounded border bg-background text-xs transition-all duration-150 select-none',
|
||||
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
|
||||
'relative rounded border text-xs transition-colors duration-150',
|
||||
isDragging ? 'opacity-40 border-blue-300' : 'border-border/60',
|
||||
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-background',
|
||||
)}
|
||||
>
|
||||
{/* 章节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
||||
{!readOnly && (
|
||||
<GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab active:cursor-grabbing" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */}
|
||||
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-3 z-20 pointer-events-none" />
|
||||
{beforeDrop.isOver && <DropLine position="top" />}
|
||||
|
||||
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/50 shrink-0">
|
||||
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 小节列表 (Level 2) */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
|
||||
{keyPoints.map((kp, i) => (
|
||||
<KeyPointRow
|
||||
key={i}
|
||||
keyPoint={kp}
|
||||
onChange={(updated) => updateKeyPoint(i, updated)}
|
||||
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{/* into:包住头部 + 折叠体;本身就是 drop 容器 */}
|
||||
<div ref={intoDrop.setNodeRef}>
|
||||
{/* 章节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
|
||||
{...listeners}
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/40 hover:text-muted-foreground"
|
||||
aria-label="拖动排序章节"
|
||||
title={DRAG_HINT}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加小节
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/50 shrink-0">
|
||||
{keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
|
||||
{/* 小节列表 (Level 2) */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-6 pb-2.5 pt-1 space-y-1.5 border-t">
|
||||
{keyPoints.map((kp, i) => (
|
||||
<KeyPointRow
|
||||
key={i}
|
||||
keyPoint={kp}
|
||||
sectionIndex={sectionIndex}
|
||||
keyPointIndex={i}
|
||||
onChange={(updated) => updateKeyPoint(i, updated)}
|
||||
onRemove={readOnly ? undefined : () => removeKeyPoint(i)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加小节
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
|
||||
{/* after:底部 12px 命中区 */}
|
||||
<div ref={afterDrop.setNodeRef} className="absolute bottom-0 inset-x-0 h-3 z-20 pointer-events-none" />
|
||||
{afterDrop.isOver && <DropLine position="bottom" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
|
||||
// ─── 小节 (Level 2) ───────────────────────────────────────────────────────────
|
||||
interface KeyPointRowProps {
|
||||
keyPoint: KeyPoint;
|
||||
sectionIndex: number;
|
||||
keyPointIndex: number;
|
||||
onChange: (updated: KeyPoint) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
function KeyPointRow({ keyPoint, sectionIndex, keyPointIndex, onChange, onRemove, readOnly }: KeyPointRowProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
|
||||
|
||||
const id = `s${sectionIndex}-k${keyPointIndex}`;
|
||||
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
|
||||
id, disabled: readOnly,
|
||||
});
|
||||
const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly });
|
||||
const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly });
|
||||
const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly });
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
@@ -181,120 +205,222 @@ function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProp
|
||||
onChange({ ...keyPoint, pointTitle: trimmed });
|
||||
}
|
||||
}
|
||||
|
||||
function updateSubPoint(i: number, value: string) {
|
||||
const next = [...keyPoint.subPoints];
|
||||
next[i] = value;
|
||||
onChange({ ...keyPoint, subPoints: next });
|
||||
}
|
||||
|
||||
function removeSubPoint(i: number) {
|
||||
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addSubPoint() {
|
||||
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
|
||||
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border/40 bg-muted/10">
|
||||
{/* 小节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
aria-label="展开/收起子要点"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/60 transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
ref={setDragRef}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
'relative rounded border border-border/40 transition-colors duration-150',
|
||||
isDragging && 'opacity-40 border-blue-300',
|
||||
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-muted/10',
|
||||
)}
|
||||
>
|
||||
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-2 z-20 pointer-events-none" />
|
||||
{beforeDrop.isOver && <DropLine position="top" />}
|
||||
|
||||
<span className="text-purple-500/70 shrink-0">###</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none"
|
||||
placeholder="小节标题"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
|
||||
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
|
||||
aria-label="删除小节"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
|
||||
{keyPoint.subPoints.map((sp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1">
|
||||
{/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */}
|
||||
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
|
||||
{readOnly ? (
|
||||
<span className="flex-1 text-muted-foreground">{sp}</span>
|
||||
) : (
|
||||
<input
|
||||
value={sp}
|
||||
onChange={(e) => updateSubPoint(i, e.target.value)}
|
||||
placeholder="子要点标题(会展开成一段正文)"
|
||||
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSubPoint(i)}
|
||||
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
|
||||
aria-label="删除子要点"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={intoDrop.setNodeRef}>
|
||||
{/* 小节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSubPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
|
||||
{...listeners}
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/30 hover:text-muted-foreground"
|
||||
aria-label="拖动排序小节"
|
||||
title={DRAG_HINT}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加子要点
|
||||
<GripVertical className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
aria-label="展开/收起子要点"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/60 transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span className="text-purple-500/70 shrink-0">###</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none"
|
||||
placeholder="小节标题"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{keyPoint.pointTitle || <span className="text-muted-foreground italic">(未命名小节)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/40 shrink-0 text-[10px]">
|
||||
{keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'}
|
||||
</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground/40 hover:text-destructive"
|
||||
aria-label="删除小节"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
|
||||
{/* 子要点 (Level 3) */}
|
||||
<CollapseTransition open={expanded}>
|
||||
<div className="px-6 pb-1.5 space-y-0.5 border-t border-border/30 bg-background/40">
|
||||
{keyPoint.subPoints.map((sp, i) => (
|
||||
<SubPointRow
|
||||
key={i}
|
||||
value={sp}
|
||||
sectionIndex={sectionIndex}
|
||||
keyPointIndex={keyPointIndex}
|
||||
subPointIndex={i}
|
||||
onChange={(value) => updateSubPoint(i, value)}
|
||||
onRemove={readOnly ? undefined : () => removeSubPoint(i)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSubPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加子要点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
|
||||
<div ref={afterDrop.setNodeRef} className="absolute bottom-0 inset-x-0 h-2 z-20 pointer-events-none" />
|
||||
{afterDrop.isOver && <DropLine position="bottom" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 子要点 (Level 3,叶节点,无 into) ────────────────────────────────────────
|
||||
interface SubPointRowProps {
|
||||
value: string;
|
||||
sectionIndex: number;
|
||||
keyPointIndex: number;
|
||||
subPointIndex: number;
|
||||
onChange: (value: string) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function SubPointRow({
|
||||
value, sectionIndex, keyPointIndex, subPointIndex, onChange, onRemove, readOnly,
|
||||
}: SubPointRowProps) {
|
||||
const id = `s${sectionIndex}-k${keyPointIndex}-p${subPointIndex}`;
|
||||
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
|
||||
id, disabled: readOnly,
|
||||
});
|
||||
// 叶节点:没有 into;顶半区 = before、底半区 = after,把整行高度铺满
|
||||
const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly });
|
||||
const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setDragRef}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
'relative flex items-center gap-1.5 pt-1 rounded transition-colors duration-150',
|
||||
isDragging && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={beforeDrop.setNodeRef}
|
||||
className="absolute left-0 right-0 top-0 bottom-1/2 z-20 pointer-events-none"
|
||||
/>
|
||||
{beforeDrop.isOver && <DropLine position="top" thin />}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
{...listeners}
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none p-0.5 -m-0.5 text-muted-foreground/30 hover:text-muted-foreground"
|
||||
aria-label="拖动排序子要点"
|
||||
title={DRAG_HINT_LEAF}
|
||||
>
|
||||
<GripVertical className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-pink-500/70 shrink-0 text-[10px] font-mono">####</span>
|
||||
{readOnly ? (
|
||||
<span className="flex-1 text-muted-foreground">{value}</span>
|
||||
) : (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="子要点标题(会展开成一段正文)"
|
||||
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 text-muted-foreground/30 hover:text-destructive"
|
||||
aria-label="删除子要点"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={afterDrop.setNodeRef}
|
||||
className="absolute left-0 right-0 top-1/2 bottom-0 z-20 pointer-events-none"
|
||||
/>
|
||||
{afterDrop.isOver && <DropLine position="bottom" thin />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 蓝色插入指示线 ───────────────────────────────────────────────────────────
|
||||
function DropLine({ position, thin }: { position: 'top' | 'bottom'; thin?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-1 bg-blue-500 rounded-full z-30 pointer-events-none',
|
||||
thin ? 'h-[2px]' : 'h-[3px]',
|
||||
position === 'top' ? '-top-px' : '-bottom-px',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SetupWritingApprovalPayload,
|
||||
} from '@/components/workspace/messages';
|
||||
import { ThreadContext } from '@/components/workspace/messages/context';
|
||||
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils';
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { useModels } from '@/core/models/hooks';
|
||||
import { useThreadStream } from '@/core/threads/hooks';
|
||||
@@ -29,6 +30,8 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
|
||||
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { WritingWelcome } from './WritingWelcome';
|
||||
|
||||
/**
|
||||
@@ -61,6 +64,9 @@ export function WritingSetupChat({
|
||||
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
||||
const formCtx = useWritingFormOptional();
|
||||
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
|
||||
// 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。
|
||||
const aiWriting = useAIWritingOptional();
|
||||
const canStartWriting = !onApproveProp && !!aiWriting;
|
||||
|
||||
// 首个模型可用时默认选中
|
||||
useEffect(() => {
|
||||
@@ -116,9 +122,68 @@ export function WritingSetupChat({
|
||||
[applyProposal],
|
||||
);
|
||||
|
||||
// 「直接开始写作」:意图确认卡里已带上**用户已确认的大纲**(setup agent 在确认大纲后才提交),
|
||||
// 这里把审批建议(含该大纲)直接组装成写作请求并启动 ai_writing 流程,同时回填表单保持一致。
|
||||
const handleStart = useCallback(
|
||||
async (payload: SetupWritingApprovalPayload) => {
|
||||
if (!aiWriting) return;
|
||||
applyProposal?.(payload);
|
||||
|
||||
const wordCount =
|
||||
typeof payload.word_count_target === 'number' && payload.word_count_target > 0
|
||||
? payload.word_count_target
|
||||
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
|
||||
const materialSource = formCtx?.materialSource ?? 'general';
|
||||
|
||||
const request: AIWritingRequest = {
|
||||
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
|
||||
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
|
||||
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
|
||||
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
|
||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
|
||||
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
|
||||
targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者',
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose',
|
||||
// 跟随表单的「添加参考文献」开关(默认开)。
|
||||
addReferences: formCtx?.addReferences ?? true,
|
||||
materialSource,
|
||||
modelName: chatModel || formCtx?.modelName || undefined,
|
||||
noteSearchSpaceId:
|
||||
materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
|
||||
noteSourceIds:
|
||||
materialSource === 'notebook' && formCtx?.selectedNotebookId != null
|
||||
? formCtx.selectedDocumentIds
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!request.userIntent) {
|
||||
toast.error('写作意图为空,请先补充意图');
|
||||
return;
|
||||
}
|
||||
await aiWriting.startWriting(request);
|
||||
},
|
||||
[aiWriting, applyProposal, formCtx, chatModel],
|
||||
);
|
||||
|
||||
// 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致)
|
||||
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
|
||||
|
||||
// 首 token 到达前的「正在整理…」占位:模型在跑(isLoading),但当前助手回合还没有任何
|
||||
// 可见输出(无答案文本、无 <think> 思考、无工具调用)—— 这段纯网络/排队延迟下答案区空白,
|
||||
// 加个轻量提示避免用户以为卡死(思考一旦开始流式,此提示自动消失,改由思考区接力)。
|
||||
const showThinkingPlaceholder = useMemo(() => {
|
||||
if (!thread.isLoading) return false;
|
||||
const last = thread.messages[thread.messages.length - 1];
|
||||
if (!last || last.type !== 'ai') return true; // 还停在用户消息,AI 尚未开口
|
||||
const hasOutput =
|
||||
!!extractContentFromMessage(last).trim() ||
|
||||
!!extractReasoningContentFromMessage(last) ||
|
||||
hasToolCalls(last);
|
||||
return !hasOutput;
|
||||
}, [thread.isLoading, thread.messages]);
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
|
||||
}, [handleChatSubmit]);
|
||||
@@ -147,10 +212,21 @@ export function WritingSetupChat({
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
onSetupWritingApprove={handleApprove}
|
||||
onSetupWritingReject={handleReject}
|
||||
onSetupWritingStart={canStartWriting ? handleStart : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 首 token 前的「正在整理…」占位:避免模型慢/排队时答案区空白像卡死 */}
|
||||
{showThinkingPlaceholder && (
|
||||
<div className="shrink-0 border-t bg-background px-4 py-2 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
正在整理…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
|
||||
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
|
||||
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import { countArticleWords } from '../utils/wordCount';
|
||||
import {
|
||||
INITIAL_STATE,
|
||||
customToProgressEvent,
|
||||
@@ -280,10 +280,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
// 用户自带大纲原文(留空则后端走自动规划)
|
||||
user_outline_raw: request.userOutline?.trim() || '',
|
||||
article_type: request.articleType,
|
||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
|
||||
article_type_detail: request.articleTypeDetail?.trim() || '',
|
||||
target_audience: request.targetAudience,
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
|
||||
add_references: request.addReferences ?? false,
|
||||
material_source: request.materialSource,
|
||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||
notebook_source_ids: request.noteSourceIds ?? null,
|
||||
@@ -328,7 +332,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||
else if (pausePoint === 'draft_confirm') {
|
||||
record.draftTitle = state.currentDraftTitle;
|
||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||
record.draftWordCount = countArticleWords(state.currentDraftMarkdown);
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
@@ -342,7 +346,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
pendingRevisionContext = {
|
||||
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
||||
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
|
||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||
oldWordCount: countArticleWords(state.currentDraftMarkdown),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface WritingFormValue {
|
||||
setAudience: (v: string) => void;
|
||||
strictMode: boolean;
|
||||
setStrictMode: (v: boolean) => void;
|
||||
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认开。 */
|
||||
addReferences: boolean;
|
||||
setAddReferences: (v: boolean) => void;
|
||||
materialSource: 'general' | 'notebook';
|
||||
setMaterialSource: (v: 'general' | 'notebook') => void;
|
||||
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
|
||||
@@ -81,6 +84,7 @@ export function WritingFormProvider({
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [addReferences, setAddReferences] = useState(true);
|
||||
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
@@ -117,13 +121,14 @@ export function WritingFormProvider({
|
||||
wordCountInput, setWordCountInput,
|
||||
audience, setAudience,
|
||||
strictMode, setStrictMode,
|
||||
addReferences, setAddReferences,
|
||||
materialSource, setMaterialSource,
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
selectedDocumentIds, setSelectedDocumentIds,
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
||||
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
||||
*/
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import { countArticleWords } from '../utils/wordCount';
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
AIWritingState,
|
||||
@@ -339,7 +339,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
|
||||
case 'draft_ready': {
|
||||
const newMarkdown = event.draftMarkdown ?? '';
|
||||
const newWordCount = countWords(newMarkdown);
|
||||
const newWordCount = countArticleWords(newMarkdown);
|
||||
const summary = prev.pendingRevisionContext
|
||||
? {
|
||||
revisedIssues: prev.pendingRevisionContext.submittedIssues,
|
||||
@@ -701,6 +701,10 @@ export type AIWritingValues = {
|
||||
user_intent?: string;
|
||||
user_outline_raw?: string;
|
||||
article_type?: string;
|
||||
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
|
||||
article_type_detail?: string;
|
||||
/** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */
|
||||
add_references?: boolean;
|
||||
status?: string;
|
||||
material_package?: unknown;
|
||||
current_outline?: unknown;
|
||||
|
||||
@@ -25,25 +25,33 @@ export function useWritingEditor({
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||
|
||||
// 编辑器自身最近一次 emit 出去的 markdown。父级把它存进 state 后会作为新的 `markdown` prop
|
||||
// 回灌进来,下面的同步 effect 据此识别「这是我自己刚产生的变更」并跳过 setContent。
|
||||
const lastEmittedRef = useRef<string | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editable,
|
||||
content: markdown,
|
||||
onUpdate({ editor }) {
|
||||
const fn = onChangeRef.current;
|
||||
if (!fn) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
|
||||
fn(md);
|
||||
lastEmittedRef.current = md;
|
||||
onChangeRef.current?.(md);
|
||||
},
|
||||
}, []);
|
||||
|
||||
// Sync external markdown changes (e.g. after streaming completes)
|
||||
// Sync external markdown changes (e.g. after streaming completes / AI 改写)
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (markdown === prevMarkdownRef.current) return;
|
||||
prevMarkdownRef.current = markdown;
|
||||
|
||||
// 跳过「编辑器自身刚 emit、又被父级回灌」的变更:此时编辑器内容已经是最新的,再 setContent
|
||||
// 会重新解析整篇文档、重置光标。markdown 序列化对空格/换行是有损的(如行尾空格被裁剪),
|
||||
// 自激回灌一旦触发 setContent,就会把刚输入的空格吞掉并让光标跳到下一个字(本次修的 bug)。
|
||||
if (markdown === lastEmittedRef.current) return;
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
editor.commands.setContent(markdown, { emitUpdate: false });
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
@@ -254,6 +254,8 @@ export interface AIWritingRequest {
|
||||
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
||||
userOutline?: string;
|
||||
articleType: string;
|
||||
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
|
||||
articleTypeDetail?: string;
|
||||
targetAudience: string;
|
||||
wordCountTarget: number;
|
||||
modelName?: string;
|
||||
@@ -261,6 +263,8 @@ export interface AIWritingRequest {
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: "strict" | "loose";
|
||||
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
||||
addReferences?: boolean;
|
||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
|
||||
materialSource: "general" | "notebook";
|
||||
/** 笔记来源(可选):空间 search_space_id */
|
||||
|
||||
@@ -10,3 +10,15 @@ export function countWords(text: string): number {
|
||||
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
|
||||
return cjk + cjkPunct + alnumRuns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文字数:与后端 word_count 同口径——**不计文末「参考文献」列表**。
|
||||
* 后端在字数收敛后才追加 `## 参考文献` 段,其 word_count 只统计正文;前端展示「实际 vs 目标」
|
||||
* 也必须排除该段,否则会因文献列表虚高几十~几百字,让用户误以为严重超标。
|
||||
*/
|
||||
export function countArticleWords(markdown: string): number {
|
||||
if (!markdown) return 0;
|
||||
const idx = markdown.lastIndexOf('## 参考文献');
|
||||
const body = idx >= 0 ? markdown.slice(0, idx) : markdown;
|
||||
return countWords(body);
|
||||
}
|
||||
Reference in new issue
Block a user