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
+1265
-344
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
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 { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ export function MessageList({
|
|||||||
onSetupAgentReject,
|
onSetupAgentReject,
|
||||||
onSetupWritingApprove,
|
onSetupWritingApprove,
|
||||||
onSetupWritingReject,
|
onSetupWritingReject,
|
||||||
|
onSetupWritingStart,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@@ -120,6 +121,8 @@ export function MessageList({
|
|||||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
|
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||||
|
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
@@ -430,6 +433,7 @@ export function MessageList({
|
|||||||
message={message}
|
message={message}
|
||||||
onApprove={onSetupWritingApprove}
|
onApprove={onSetupWritingApprove}
|
||||||
onReject={onSetupWritingReject}
|
onReject={onSetupWritingReject}
|
||||||
|
onStart={onSetupWritingStart}
|
||||||
/>
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -629,6 +633,8 @@ export type SetupAgentApprovalPayload = {
|
|||||||
export type SetupWritingApprovalPayload = {
|
export type SetupWritingApprovalPayload = {
|
||||||
user_intent?: string;
|
user_intent?: string;
|
||||||
article_type?: string;
|
article_type?: string;
|
||||||
|
/** 当 article_type 是已有类型库里没有的新类型时,模型识别出的一句话特征说明(命中已有类型时为空)。 */
|
||||||
|
article_type_detail?: string;
|
||||||
word_count_target?: number | null;
|
word_count_target?: number | null;
|
||||||
target_audience?: string;
|
target_audience?: string;
|
||||||
writing_mode?: string;
|
writing_mode?: string;
|
||||||
@@ -820,14 +826,16 @@ function SetupWritingApprovalCard({
|
|||||||
message,
|
message,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
|
onStart,
|
||||||
}: {
|
}: {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
message: unknown;
|
message: unknown;
|
||||||
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
|
onStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const payload = getSetupWritingApprovalPayload(message);
|
const payload = getSetupWritingApprovalPayload(message);
|
||||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
const [submitting, setSubmitting] = useState<"approve" | "reject" | "start" | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -838,7 +846,7 @@ function SetupWritingApprovalCard({
|
|||||||
const approvalPayload = payload;
|
const approvalPayload = payload;
|
||||||
const busy = disabled || submitting !== null;
|
const busy = disabled || submitting !== null;
|
||||||
|
|
||||||
async function run(action: "approve" | "reject") {
|
async function run(action: "approve" | "reject" | "start") {
|
||||||
if (busy) {
|
if (busy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -846,6 +854,8 @@ function SetupWritingApprovalCard({
|
|||||||
try {
|
try {
|
||||||
if (action === "approve") {
|
if (action === "approve") {
|
||||||
await onApprove?.(approvalPayload);
|
await onApprove?.(approvalPayload);
|
||||||
|
} else if (action === "start") {
|
||||||
|
await onStart?.(approvalPayload);
|
||||||
} else {
|
} else {
|
||||||
await onReject?.(approvalPayload);
|
await onReject?.(approvalPayload);
|
||||||
}
|
}
|
||||||
@@ -854,9 +864,14 @@ function SetupWritingApprovalCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasNewType = !!approvalPayload.article_type_detail?.trim();
|
||||||
const rows: Array<{ label: string; value: string }> = [];
|
const rows: Array<{ label: string; value: string }> = [];
|
||||||
if (approvalPayload.article_type) {
|
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") {
|
if (typeof approvalPayload.word_count_target === "number") {
|
||||||
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
|
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
|
||||||
@@ -903,6 +918,13 @@ function SetupWritingApprovalCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{approvalPayload.outline ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-muted-foreground text-xs">已有大纲</div>
|
<div className="text-muted-foreground text-xs">已有大纲</div>
|
||||||
@@ -926,6 +948,7 @@ function SetupWritingApprovalCard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant={onStart ? "outline" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !onApprove}
|
disabled={busy || !onApprove}
|
||||||
onClick={() => void run("approve")}
|
onClick={() => void run("approve")}
|
||||||
@@ -933,6 +956,17 @@ function SetupWritingApprovalCard({
|
|||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
应用到表单
|
应用到表单
|
||||||
</Button>
|
</Button>
|
||||||
|
{onStart ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void run("start")}
|
||||||
|
>
|
||||||
|
<PenLineIcon className="h-4 w-4" />
|
||||||
|
直接开始写作
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -212,21 +212,33 @@ export function extractToolResultPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||||
|
const THINK_OPEN = "<think>";
|
||||||
|
|
||||||
function splitInlineReasoning(content: string) {
|
function splitInlineReasoning(content: string) {
|
||||||
const reasoningParts: string[] = [];
|
const reasoningParts: string[] = [];
|
||||||
const cleaned = content
|
// 1) 先抽出所有**已闭合**的 <think>...</think>。
|
||||||
.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
let working = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||||
const normalized = reasoning.trim();
|
const normalized = reasoning.trim();
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
reasoningParts.push(normalized);
|
reasoningParts.push(normalized);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
})
|
});
|
||||||
.trim();
|
|
||||||
|
// 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 {
|
return {
|
||||||
content: cleaned,
|
content: working.trim(),
|
||||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
|
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function AIWritingForm({
|
|||||||
audience, setAudience,
|
audience, setAudience,
|
||||||
wordCountInput, setWordCountInput,
|
wordCountInput, setWordCountInput,
|
||||||
strictMode, setStrictMode,
|
strictMode, setStrictMode,
|
||||||
|
addReferences, setAddReferences,
|
||||||
materialSource, setMaterialSource,
|
materialSource, setMaterialSource,
|
||||||
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
|
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
|
||||||
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
|
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
|
||||||
@@ -164,6 +165,7 @@ export function AIWritingForm({
|
|||||||
wordCountTarget: wordCount,
|
wordCountTarget: wordCount,
|
||||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||||
|
addReferences,
|
||||||
materialSource: materialSource,
|
materialSource: materialSource,
|
||||||
modelName: selectedModel?.name || modelName || undefined,
|
modelName: selectedModel?.name || modelName || undefined,
|
||||||
// 只有选了"我的空间"才传笔记参数
|
// 只有选了"我的空间"才传笔记参数
|
||||||
@@ -381,6 +383,23 @@ export function AIWritingForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled || !intent.trim()}
|
disabled={disabled || !intent.trim()}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
|
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
|
||||||
*/
|
*/
|
||||||
const ENABLE_PAUSE_RESUME_UI = false;
|
const ENABLE_PAUSE_RESUME_UI = false;
|
||||||
|
import { countArticleWords } from '../../utils/wordCount';
|
||||||
import { AIWritingForm } from './AIWritingForm';
|
import { AIWritingForm } from './AIWritingForm';
|
||||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||||
import { InterventionCard } from './InterventionCard';
|
import { InterventionCard } from './InterventionCard';
|
||||||
@@ -482,6 +483,8 @@ export function AIWritingPanel({
|
|||||||
outline={state.outline}
|
outline={state.outline}
|
||||||
reviewResult={state.reviewResult}
|
reviewResult={state.reviewResult}
|
||||||
draftTitle={state.currentDraftTitle}
|
draftTitle={state.currentDraftTitle}
|
||||||
|
draftWordCount={countArticleWords(state.currentDraftMarkdown)}
|
||||||
|
wordCountTarget={state.request?.wordCountTarget}
|
||||||
revisionCount={state.revisionCount}
|
revisionCount={state.revisionCount}
|
||||||
skillSearchSteps={state.skillSearchSteps}
|
skillSearchSteps={state.skillSearchSteps}
|
||||||
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
|
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ interface InterventionCardProps {
|
|||||||
outline: AIOutline | null;
|
outline: AIOutline | null;
|
||||||
reviewResult: ReviewResult | null;
|
reviewResult: ReviewResult | null;
|
||||||
draftTitle: string;
|
draftTitle: string;
|
||||||
|
/** 当前草稿实际字数(与目标对比展示,让用户在确认时即看到字数是否达标) */
|
||||||
|
draftWordCount?: number;
|
||||||
|
/** 用户设定的目标字数 */
|
||||||
|
wordCountTarget?: number;
|
||||||
revisionCount: number;
|
revisionCount: number;
|
||||||
skillSearchSteps?: SkillSearchStep[];
|
skillSearchSteps?: SkillSearchStep[];
|
||||||
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
|
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
|
||||||
@@ -34,7 +38,7 @@ interface InterventionCardProps {
|
|||||||
|
|
||||||
export function InterventionCard({
|
export function InterventionCard({
|
||||||
pauseEvent, materials, outline, reviewResult,
|
pauseEvent, materials, outline, reviewResult,
|
||||||
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
|
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
|
||||||
}: InterventionCardProps) {
|
}: InterventionCardProps) {
|
||||||
const pausePoint = pauseEvent.pausePoint;
|
const pausePoint = pauseEvent.pausePoint;
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ export function InterventionCard({
|
|||||||
return (
|
return (
|
||||||
<DraftConfirmCard
|
<DraftConfirmCard
|
||||||
draftTitle={draftTitle}
|
draftTitle={draftTitle}
|
||||||
|
draftWordCount={draftWordCount}
|
||||||
|
wordCountTarget={wordCountTarget}
|
||||||
revisionCount={revisionCount}
|
revisionCount={revisionCount}
|
||||||
reviewResult={reviewResult}
|
reviewResult={reviewResult}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
@@ -357,13 +363,15 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
|
|||||||
|
|
||||||
interface DraftConfirmCardProps {
|
interface DraftConfirmCardProps {
|
||||||
draftTitle: string;
|
draftTitle: string;
|
||||||
|
draftWordCount?: number;
|
||||||
|
wordCountTarget?: number;
|
||||||
revisionCount: number;
|
revisionCount: number;
|
||||||
reviewResult: ReviewResult | null;
|
reviewResult: ReviewResult | null;
|
||||||
onSubmit: (payload: UserInterventionPayload) => void;
|
onSubmit: (payload: UserInterventionPayload) => void;
|
||||||
disabled?: boolean;
|
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 [notes, setNotes] = useState('');
|
||||||
const [showNotes, setShowNotes] = useState(false);
|
const [showNotes, setShowNotes] = useState(false);
|
||||||
const [submitting, setSubmitting] = 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;
|
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 (
|
return (
|
||||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -410,6 +423,12 @@ function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, d
|
|||||||
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
|
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
|
||||||
· 右侧可预览完整内容
|
· 右侧可预览完整内容
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { useState } from 'react';
|
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 { 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 { OutlineSection } from './OutlineSection';
|
||||||
import { CollapseTransition } from './CollapseTransition';
|
import { CollapseTransition } from './CollapseTransition';
|
||||||
|
|
||||||
@@ -11,15 +25,177 @@ interface OutlineCardProps {
|
|||||||
readOnly?: boolean;
|
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) {
|
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState(outline.title);
|
const [titleDraft, setTitleDraft] = useState(outline.title);
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const editable = !readOnly && !!onChange;
|
const editable = !readOnly && !!onChange;
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||||
|
|
||||||
function commitTitle() {
|
function commitTitle() {
|
||||||
setEditingTitle(false);
|
setEditingTitle(false);
|
||||||
const trimmed = titleDraft.trim();
|
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) });
|
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() {
|
function addSection() {
|
||||||
onChange?.({
|
onChange?.({
|
||||||
...outline,
|
...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 (
|
return (
|
||||||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||||||
<span className="text-xs font-medium flex-1">文章大纲</span>
|
<span className="text-xs font-medium flex-1">文章大纲</span>
|
||||||
@@ -97,34 +322,30 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 章节列表 */}
|
{/* 章节列表 —— 三区域拖放:顶/底边重排,中间嵌入为子项 */}
|
||||||
<div className="space-y-1.5">
|
<DndContext
|
||||||
{outline.sections.map((section, i) => (
|
sensors={sensors}
|
||||||
<OutlineSection
|
collisionDetection={innermostCollision}
|
||||||
key={i}
|
onDragStart={handleDragStart}
|
||||||
section={section}
|
onDragEnd={handleDragEnd}
|
||||||
index={i}
|
onDragCancel={handleDragCancel}
|
||||||
onChange={(updated) => updateSection(i, updated)}
|
>
|
||||||
onRemove={editable ? () => removeSection(i) : undefined}
|
<div className="space-y-1.5">
|
||||||
readOnly={!editable}
|
{outline.sections.map((section, i) => (
|
||||||
isDragging={dragIndex === i}
|
<OutlineSection
|
||||||
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
|
key={i}
|
||||||
onDragStart={() => setDragIndex(i)}
|
section={section}
|
||||||
onDragEnter={() => setOverIndex(i)}
|
index={i}
|
||||||
onDrop={() => {
|
sectionIndex={i}
|
||||||
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
|
onChange={(updated) => updateSection(i, updated)}
|
||||||
setDragIndex(null);
|
onRemove={editable ? () => removeSection(i) : undefined}
|
||||||
setOverIndex(null);
|
readOnly={!editable}
|
||||||
}}
|
/>
|
||||||
onDragEnd={() => {
|
))}
|
||||||
setDragIndex(null);
|
</div>
|
||||||
setOverIndex(null);
|
<DragOverlay dropAnimation={null}>{renderDragOverlay()}</DragOverlay>
|
||||||
}}
|
</DndContext>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 添加章节 */}
|
|
||||||
{editable && (
|
{editable && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,3 +363,23 @@ export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCard
|
|||||||
</div>
|
</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 { useState, useMemo } from 'react';
|
||||||
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
|
import { ChevronRight, Plus, X, GripVertical } from 'lucide-react';
|
||||||
|
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
import type { KeyPoint, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||||
import { normalizeKeyPoint } from '../../types/ai-writing';
|
import { normalizeKeyPoint } from '../../types/ai-writing';
|
||||||
@@ -8,36 +9,34 @@ import { CollapseTransition } from './CollapseTransition';
|
|||||||
interface OutlineSectionProps {
|
interface OutlineSectionProps {
|
||||||
section: OutlineSectionType;
|
section: OutlineSectionType;
|
||||||
index: number;
|
index: number;
|
||||||
|
/** 章节在 outline.sections 中的下标,用于生成稳定 dnd id */
|
||||||
|
sectionIndex: number;
|
||||||
onChange: (updated: OutlineSectionType) => void;
|
onChange: (updated: OutlineSectionType) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
// Drag-and-drop (章节级别)
|
|
||||||
isDragging?: boolean;
|
|
||||||
isOver?: boolean;
|
|
||||||
onDragStart?: () => void;
|
|
||||||
onDragEnter?: () => void;
|
|
||||||
onDrop?: () => void;
|
|
||||||
onDragEnd?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 各级 grip 标题提示文案
|
||||||
* 章节卡片(Level 1)。内部渲染 keyPoints[] 为「小节子卡片」(Level 2),小节里再渲染
|
const DRAG_HINT = '按住拖动:项的顶/底边 = 同级重排;中间区域 = 嵌入为子项';
|
||||||
* subPoints[] 为 bullet 列表(Level 3)。
|
const DRAG_HINT_LEAF = '按住拖动重排(顶半区 = 上插入,底半区 = 下插入)';
|
||||||
*
|
|
||||||
* 兼容旧 2 级大纲:section.keyPoints 可能是 (string | KeyPoint)[];进入组件前通过
|
// ─── 章节 (Level 1) ───────────────────────────────────────────────────────────
|
||||||
* normalizeKeyPoint 统一成 KeyPoint[],对外输出也一律是 KeyPoint[](自然升级,用户
|
|
||||||
* 编辑过的旧 session 大纲会被持久化为新格式)。
|
|
||||||
*/
|
|
||||||
export function OutlineSection({
|
export function OutlineSection({
|
||||||
section, index, onChange, onRemove, readOnly,
|
section, index, sectionIndex, onChange, onRemove, readOnly,
|
||||||
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
|
|
||||||
}: OutlineSectionProps) {
|
}: OutlineSectionProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
|
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(
|
const keyPoints: KeyPoint[] = useMemo(
|
||||||
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
|
() => (section.keyPoints ?? []).map(normalizeKeyPoint),
|
||||||
[section.keyPoints],
|
[section.keyPoints],
|
||||||
@@ -52,128 +51,153 @@ export function OutlineSection({
|
|||||||
setTitleDraft(section.sectionTitle);
|
setTitleDraft(section.sectionTitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKeyPoint(i: number, updated: KeyPoint) {
|
function updateKeyPoint(i: number, updated: KeyPoint) {
|
||||||
const next = [...keyPoints];
|
const next = [...keyPoints];
|
||||||
next[i] = updated;
|
next[i] = updated;
|
||||||
onChange({ ...section, keyPoints: next });
|
onChange({ ...section, keyPoints: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeKeyPoint(i: number) {
|
function removeKeyPoint(i: number) {
|
||||||
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
|
onChange({ ...section, keyPoints: keyPoints.filter((_, idx) => idx !== i) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function addKeyPoint() {
|
function addKeyPoint() {
|
||||||
onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
|
onChange({ ...section, keyPoints: [...keyPoints, { pointTitle: '', subPoints: [] }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 子要点总数,显示在章节头给个粗略概览
|
|
||||||
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
|
const totalSubPoints = keyPoints.reduce((sum, kp) => sum + (kp.subPoints?.length || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable={!readOnly}
|
ref={setDragRef}
|
||||||
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
|
{...attributes}
|
||||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
|
|
||||||
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
|
|
||||||
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded border bg-background text-xs transition-all duration-150 select-none',
|
'relative rounded border text-xs transition-colors duration-150',
|
||||||
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
|
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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 章节头 */}
|
{/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */}
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-3 z-20 pointer-events-none" />
|
||||||
{!readOnly && (
|
{beforeDrop.isOver && <DropLine position="top" />}
|
||||||
<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>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
|
{/* into:包住头部 + 折叠体;本身就是 drop 容器 */}
|
||||||
|
<div ref={intoDrop.setNodeRef}>
|
||||||
{editingTitle && !readOnly ? (
|
{/* 章节头 */}
|
||||||
<input
|
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
||||||
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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addKeyPoint}
|
{...listeners}
|
||||||
className="flex items-center gap-1 text-muted-foreground/60 hover:text-primary pt-0.5"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 小节行(Level 2,内嵌 Level 3 子要点) ───────────────────────────────────────
|
// ─── 小节 (Level 2) ───────────────────────────────────────────────────────────
|
||||||
interface KeyPointRowProps {
|
interface KeyPointRowProps {
|
||||||
keyPoint: KeyPoint;
|
keyPoint: KeyPoint;
|
||||||
|
sectionIndex: number;
|
||||||
|
keyPointIndex: number;
|
||||||
onChange: (updated: KeyPoint) => void;
|
onChange: (updated: KeyPoint) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) {
|
function KeyPointRow({ keyPoint, sectionIndex, keyPointIndex, onChange, onRemove, readOnly }: KeyPointRowProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle);
|
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() {
|
function commitTitle() {
|
||||||
setEditingTitle(false);
|
setEditingTitle(false);
|
||||||
const trimmed = titleDraft.trim();
|
const trimmed = titleDraft.trim();
|
||||||
@@ -181,120 +205,222 @@ function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProp
|
|||||||
onChange({ ...keyPoint, pointTitle: trimmed });
|
onChange({ ...keyPoint, pointTitle: trimmed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSubPoint(i: number, value: string) {
|
function updateSubPoint(i: number, value: string) {
|
||||||
const next = [...keyPoint.subPoints];
|
const next = [...keyPoint.subPoints];
|
||||||
next[i] = value;
|
next[i] = value;
|
||||||
onChange({ ...keyPoint, subPoints: next });
|
onChange({ ...keyPoint, subPoints: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSubPoint(i: number) {
|
function removeSubPoint(i: number) {
|
||||||
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
|
onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSubPoint() {
|
function addSubPoint() {
|
||||||
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
|
onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] });
|
||||||
setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框
|
setExpanded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-border/40 bg-muted/10">
|
<div
|
||||||
{/* 小节头 */}
|
ref={setDragRef}
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
{...attributes}
|
||||||
<button
|
className={cn(
|
||||||
type="button"
|
'relative rounded border border-border/40 transition-colors duration-150',
|
||||||
onClick={() => setExpanded((v) => !v)}
|
isDragging && 'opacity-40 border-blue-300',
|
||||||
className="shrink-0"
|
intoDrop.isOver ? 'ring-2 ring-blue-400 ring-inset bg-blue-50/40 dark:bg-blue-950/30' : 'bg-muted/10',
|
||||||
aria-label="展开/收起子要点"
|
)}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<div ref={beforeDrop.setNodeRef} className="absolute top-0 inset-x-0 h-2 z-20 pointer-events-none" />
|
||||||
className={cn(
|
{beforeDrop.isOver && <DropLine position="top" />}
|
||||||
'size-3 text-muted-foreground/60 transition-transform duration-200',
|
|
||||||
expanded && 'rotate-90',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="text-purple-500/70 shrink-0">###</span>
|
<div ref={intoDrop.setNodeRef}>
|
||||||
|
{/* 小节头 */}
|
||||||
{editingTitle && !readOnly ? (
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addSubPoint}
|
{...listeners}
|
||||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5 text-[11px]"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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,
|
type SetupWritingApprovalPayload,
|
||||||
} from '@/components/workspace/messages';
|
} from '@/components/workspace/messages';
|
||||||
import { ThreadContext } from '@/components/workspace/messages/context';
|
import { ThreadContext } from '@/components/workspace/messages/context';
|
||||||
|
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils';
|
||||||
import { getAPIClient } from '@/core/api';
|
import { getAPIClient } from '@/core/api';
|
||||||
import { useModels } from '@/core/models/hooks';
|
import { useModels } from '@/core/models/hooks';
|
||||||
import { useThreadStream } from '@/core/threads/hooks';
|
import { useThreadStream } from '@/core/threads/hooks';
|
||||||
@@ -29,6 +30,8 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
|
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
|
||||||
|
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
|
||||||
|
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||||
import { WritingWelcome } from './WritingWelcome';
|
import { WritingWelcome } from './WritingWelcome';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +64,9 @@ export function WritingSetupChat({
|
|||||||
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
||||||
const formCtx = useWritingFormOptional();
|
const formCtx = useWritingFormOptional();
|
||||||
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
|
const applyProposal = onApproveProp ?? formCtx?.applyProposal;
|
||||||
|
// 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。
|
||||||
|
const aiWriting = useAIWritingOptional();
|
||||||
|
const canStartWriting = !onApproveProp && !!aiWriting;
|
||||||
|
|
||||||
// 首个模型可用时默认选中
|
// 首个模型可用时默认选中
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,9 +122,68 @@ export function WritingSetupChat({
|
|||||||
[applyProposal],
|
[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;
|
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 () => {
|
const handleReject = useCallback(async () => {
|
||||||
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
|
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
|
||||||
}, [handleChatSubmit]);
|
}, [handleChatSubmit]);
|
||||||
@@ -147,10 +212,21 @@ export function WritingSetupChat({
|
|||||||
onClarificationSubmit={handleChatSubmit}
|
onClarificationSubmit={handleChatSubmit}
|
||||||
onSetupWritingApprove={handleApprove}
|
onSetupWritingApprove={handleApprove}
|
||||||
onSetupWritingReject={handleReject}
|
onSetupWritingReject={handleReject}
|
||||||
|
onSetupWritingStart={canStartWriting ? handleStart : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="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">
|
<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 { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||||
import { countWords } from '../utils/wordCount';
|
import { countArticleWords } from '../utils/wordCount';
|
||||||
import {
|
import {
|
||||||
INITIAL_STATE,
|
INITIAL_STATE,
|
||||||
customToProgressEvent,
|
customToProgressEvent,
|
||||||
@@ -280,10 +280,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
// 用户自带大纲原文(留空则后端走自动规划)
|
// 用户自带大纲原文(留空则后端走自动规划)
|
||||||
user_outline_raw: request.userOutline?.trim() || '',
|
user_outline_raw: request.userOutline?.trim() || '',
|
||||||
article_type: request.articleType,
|
article_type: request.articleType,
|
||||||
|
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
|
||||||
|
article_type_detail: request.articleTypeDetail?.trim() || '',
|
||||||
target_audience: request.targetAudience,
|
target_audience: request.targetAudience,
|
||||||
word_count_target: request.wordCountTarget,
|
word_count_target: request.wordCountTarget,
|
||||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||||
|
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
|
||||||
|
add_references: request.addReferences ?? false,
|
||||||
material_source: request.materialSource,
|
material_source: request.materialSource,
|
||||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||||
notebook_source_ids: request.noteSourceIds ?? 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 === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||||
else if (pausePoint === 'draft_confirm') {
|
else if (pausePoint === 'draft_confirm') {
|
||||||
record.draftTitle = state.currentDraftTitle;
|
record.draftTitle = state.currentDraftTitle;
|
||||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
record.draftWordCount = countArticleWords(state.currentDraftMarkdown);
|
||||||
record.reviewResult = state.reviewResult ?? undefined;
|
record.reviewResult = state.reviewResult ?? undefined;
|
||||||
} else if (pausePoint === 'review_confirm') {
|
} else if (pausePoint === 'review_confirm') {
|
||||||
record.reviewResult = state.reviewResult ?? undefined;
|
record.reviewResult = state.reviewResult ?? undefined;
|
||||||
@@ -342,7 +346,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
pendingRevisionContext = {
|
pendingRevisionContext = {
|
||||||
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
||||||
skippedIssues: 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;
|
setAudience: (v: string) => void;
|
||||||
strictMode: boolean;
|
strictMode: boolean;
|
||||||
setStrictMode: (v: boolean) => void;
|
setStrictMode: (v: boolean) => void;
|
||||||
|
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认开。 */
|
||||||
|
addReferences: boolean;
|
||||||
|
setAddReferences: (v: boolean) => void;
|
||||||
materialSource: 'general' | 'notebook';
|
materialSource: 'general' | 'notebook';
|
||||||
setMaterialSource: (v: 'general' | 'notebook') => void;
|
setMaterialSource: (v: 'general' | 'notebook') => void;
|
||||||
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
|
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
|
||||||
@@ -81,6 +84,7 @@ export function WritingFormProvider({
|
|||||||
const [wordCountInput, setWordCountInput] = useState('800');
|
const [wordCountInput, setWordCountInput] = useState('800');
|
||||||
const [audience, setAudience] = useState('普通读者');
|
const [audience, setAudience] = useState('普通读者');
|
||||||
const [strictMode, setStrictMode] = useState(false);
|
const [strictMode, setStrictMode] = useState(false);
|
||||||
|
const [addReferences, setAddReferences] = useState(true);
|
||||||
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
||||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
|
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
|
||||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||||
@@ -117,13 +121,14 @@ export function WritingFormProvider({
|
|||||||
wordCountInput, setWordCountInput,
|
wordCountInput, setWordCountInput,
|
||||||
audience, setAudience,
|
audience, setAudience,
|
||||||
strictMode, setStrictMode,
|
strictMode, setStrictMode,
|
||||||
|
addReferences, setAddReferences,
|
||||||
materialSource, setMaterialSource,
|
materialSource, setMaterialSource,
|
||||||
selectedNotebookId, setSelectedNotebookId,
|
selectedNotebookId, setSelectedNotebookId,
|
||||||
selectedDocumentIds, setSelectedDocumentIds,
|
selectedDocumentIds, setSelectedDocumentIds,
|
||||||
modelName, setModelName,
|
modelName, setModelName,
|
||||||
applyProposal,
|
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>;
|
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
||||||
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
||||||
*/
|
*/
|
||||||
import { countWords } from '../utils/wordCount';
|
import { countArticleWords } from '../utils/wordCount';
|
||||||
import type {
|
import type {
|
||||||
AIWritingRequest,
|
AIWritingRequest,
|
||||||
AIWritingState,
|
AIWritingState,
|
||||||
@@ -339,7 +339,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
|||||||
|
|
||||||
case 'draft_ready': {
|
case 'draft_ready': {
|
||||||
const newMarkdown = event.draftMarkdown ?? '';
|
const newMarkdown = event.draftMarkdown ?? '';
|
||||||
const newWordCount = countWords(newMarkdown);
|
const newWordCount = countArticleWords(newMarkdown);
|
||||||
const summary = prev.pendingRevisionContext
|
const summary = prev.pendingRevisionContext
|
||||||
? {
|
? {
|
||||||
revisedIssues: prev.pendingRevisionContext.submittedIssues,
|
revisedIssues: prev.pendingRevisionContext.submittedIssues,
|
||||||
@@ -701,6 +701,10 @@ export type AIWritingValues = {
|
|||||||
user_intent?: string;
|
user_intent?: string;
|
||||||
user_outline_raw?: string;
|
user_outline_raw?: string;
|
||||||
article_type?: string;
|
article_type?: string;
|
||||||
|
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
|
||||||
|
article_type_detail?: string;
|
||||||
|
/** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */
|
||||||
|
add_references?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
material_package?: unknown;
|
material_package?: unknown;
|
||||||
current_outline?: unknown;
|
current_outline?: unknown;
|
||||||
|
|||||||
@@ -25,25 +25,33 @@ export function useWritingEditor({
|
|||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||||
|
|
||||||
|
// 编辑器自身最近一次 emit 出去的 markdown。父级把它存进 state 后会作为新的 `markdown` prop
|
||||||
|
// 回灌进来,下面的同步 effect 据此识别「这是我自己刚产生的变更」并跳过 setContent。
|
||||||
|
const lastEmittedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
editable,
|
editable,
|
||||||
content: markdown,
|
content: markdown,
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
const fn = onChangeRef.current;
|
|
||||||
if (!fn) return;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (markdown === prevMarkdownRef.current) return;
|
if (markdown === prevMarkdownRef.current) return;
|
||||||
prevMarkdownRef.current = markdown;
|
prevMarkdownRef.current = markdown;
|
||||||
|
|
||||||
|
// 跳过「编辑器自身刚 emit、又被父级回灌」的变更:此时编辑器内容已经是最新的,再 setContent
|
||||||
|
// 会重新解析整篇文档、重置光标。markdown 序列化对空格/换行是有损的(如行尾空格被裁剪),
|
||||||
|
// 自激回灌一旦触发 setContent,就会把刚输入的空格吞掉并让光标跳到下一个字(本次修的 bug)。
|
||||||
|
if (markdown === lastEmittedRef.current) return;
|
||||||
|
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
editor.commands.setContent(markdown, { emitUpdate: false });
|
editor.commands.setContent(markdown, { emitUpdate: false });
|
||||||
const docSize = editor.state.doc.content.size;
|
const docSize = editor.state.doc.content.size;
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ export interface AIWritingRequest {
|
|||||||
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
||||||
userOutline?: string;
|
userOutline?: string;
|
||||||
articleType: string;
|
articleType: string;
|
||||||
|
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
|
||||||
|
articleTypeDetail?: string;
|
||||||
targetAudience: string;
|
targetAudience: string;
|
||||||
wordCountTarget: number;
|
wordCountTarget: number;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
@@ -261,6 +263,8 @@ export interface AIWritingRequest {
|
|||||||
keywordCount: number;
|
keywordCount: number;
|
||||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||||
writingMode: "strict" | "loose";
|
writingMode: "strict" | "loose";
|
||||||
|
/** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */
|
||||||
|
addReferences?: boolean;
|
||||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
|
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
|
||||||
materialSource: "general" | "notebook";
|
materialSource: "general" | "notebook";
|
||||||
/** 笔记来源(可选):空间 search_space_id */
|
/** 笔记来源(可选):空间 search_space_id */
|
||||||
|
|||||||
@@ -10,3 +10,15 @@ export function countWords(text: string): number {
|
|||||||
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
|
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
|
||||||
return cjk + cjkPunct + alnumRuns;
|
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);
|
||||||
|
}
|
||||||
+9
-3
@@ -209,11 +209,17 @@ async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) ->
|
|||||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"按用户意见重写({notes[:30]})"}],
|
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"按用户意见重写({notes[:30]})"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
# to_editor
|
# to_editor:用户可能在「您的修改意见」里填了关注点再点「让编辑继续审核」,
|
||||||
|
# 把它带给编辑节点,让三维度审查重点核查用户在意的方面(不再被丢弃)。
|
||||||
|
editor_notes = (user_input.get("userRevisionNotes") or "").strip()
|
||||||
|
msg = (
|
||||||
|
f"提交编辑审核(已附带你的修改意见:{editor_notes[:30]})"
|
||||||
|
if editor_notes else "提交编辑审核"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"last_user_intervention": user_input,
|
"last_user_intervention": {**user_input, "_editor_notes": editor_notes},
|
||||||
"status": "reviewing",
|
"status": "reviewing",
|
||||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "提交编辑审核"}],
|
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -10,6 +10,7 @@ from langchain_core.runnables import RunnableConfig
|
|||||||
from ..state import AIWritingState, ReviewResult, ReviewIssue
|
from ..state import AIWritingState, ReviewResult, ReviewIssue
|
||||||
from ..prompts.editor_prompts import (
|
from ..prompts.editor_prompts import (
|
||||||
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
|
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
|
||||||
|
USER_FOCUS_BLOCK,
|
||||||
)
|
)
|
||||||
from ._agent_runner import (
|
from ._agent_runner import (
|
||||||
EDITOR_AGENT_ID,
|
EDITOR_AGENT_ID,
|
||||||
@@ -34,6 +35,19 @@ logger = logging.getLogger(__name__)
|
|||||||
_DEFAULT_PASS_THRESHOLD = 75
|
_DEFAULT_PASS_THRESHOLD = 75
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_focus(state: AIWritingState) -> str:
|
||||||
|
"""从干预数据取用户在「编辑审核」时附带的修改意见,拼成注入审查提示词的关注块。
|
||||||
|
|
||||||
|
仅当用户点「让编辑继续审核」且填了意见(graph.py `to_editor` 分支 stash 的
|
||||||
|
``_editor_notes``)时返回非空块;否则返回空串,三维度审查保持原样。
|
||||||
|
"""
|
||||||
|
intervention = state.get("last_user_intervention") or {}
|
||||||
|
notes = (intervention.get("_editor_notes") or "").strip()
|
||||||
|
if not notes:
|
||||||
|
return ""
|
||||||
|
return USER_FOCUS_BLOCK.format(notes=notes)
|
||||||
|
|
||||||
|
|
||||||
def _materials_summary(pkg: dict) -> str:
|
def _materials_summary(pkg: dict) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for m in (pkg.get("materials") or [])[:6]:
|
for m in (pkg.get("materials") or [])[:6]:
|
||||||
@@ -129,10 +143,13 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
|||||||
ai_writing_config = get_ai_writing_config()
|
ai_writing_config = get_ai_writing_config()
|
||||||
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
|
pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD))
|
||||||
|
|
||||||
|
# 用户在草稿确认时附带的修改意见 → 注入三维度审查,让编辑重点核查(空则不影响原审查)
|
||||||
|
user_focus = _build_user_focus(state)
|
||||||
|
|
||||||
REVIEW_TASKS = [
|
REVIEW_TASKS = [
|
||||||
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article)),
|
("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article, user_focus=user_focus)),
|
||||||
("logic", LOGIC_REVIEW_USER.format(article=article)),
|
("logic", LOGIC_REVIEW_USER.format(article=article, user_focus=user_focus)),
|
||||||
("language", LANGUAGE_REVIEW_USER.format(article=article)),
|
("language", LANGUAGE_REVIEW_USER.format(article=article, user_focus=user_focus)),
|
||||||
]
|
]
|
||||||
|
|
||||||
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
|
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
|
||||||
|
|||||||
+3
-1
@@ -151,7 +151,9 @@ def _resolve_intent_fields(
|
|||||||
llm_type = raw_type if raw_type in valid_keys else fallback_type
|
llm_type = raw_type if raw_type in valid_keys else fallback_type
|
||||||
|
|
||||||
preconfigured_type = (state.get("article_type") or "").strip()
|
preconfigured_type = (state.get("article_type") or "").strip()
|
||||||
if preconfigured_type in valid_keys:
|
if preconfigured_type:
|
||||||
|
# 用户/配置助手已明确指定文章类型(含**列表外的自定义/新类型**)→ 原样尊重,
|
||||||
|
# 不再因不在默认表里被丢弃回退成默认(否则自定义/新类型在写作时会被还原成默认)。
|
||||||
final_article_type = preconfigured_type
|
final_article_type = preconfigured_type
|
||||||
else:
|
else:
|
||||||
final_article_type = llm_type
|
final_article_type = llm_type
|
||||||
|
|||||||
+324
-54
@@ -1,6 +1,7 @@
|
|||||||
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
|
"""作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -9,9 +10,16 @@ from langchain_core.runnables import RunnableConfig
|
|||||||
|
|
||||||
from ..state import AIWritingState, DraftArticle
|
from ..state import AIWritingState, DraftArticle
|
||||||
from ..prompts.writer_prompts import (
|
from ..prompts.writer_prompts import (
|
||||||
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
|
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER,
|
||||||
|
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
||||||
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
|
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
|
||||||
)
|
)
|
||||||
|
# 复用大纲节点的解析/素材摘要:修订模式下先据意见更新大纲(含标题),再重写正文。
|
||||||
|
# writer_outline 不反向 import 本模块,无循环。
|
||||||
|
from .writer_outline import (
|
||||||
|
_parse_outline as _revise_parse_outline,
|
||||||
|
_materials_summary as _revise_materials_summary,
|
||||||
|
)
|
||||||
from ._agent_runner import (
|
from ._agent_runner import (
|
||||||
WRITER_AGENT_ID,
|
WRITER_AGENT_ID,
|
||||||
WritingAgentUnavailable,
|
WritingAgentUnavailable,
|
||||||
@@ -27,13 +35,116 @@ from ._model_fallback import (
|
|||||||
emit_model_switch_event,
|
emit_model_switch_event,
|
||||||
fallback_chain,
|
fallback_chain,
|
||||||
)
|
)
|
||||||
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
from ._utils import resolve_writing_model_name, _content_to_text, count_words, llm_json, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
|
# 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串
|
||||||
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
|
_HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE)
|
||||||
|
|
||||||
|
# ── 字数控制 ────────────────────────────────────────────────────────────────
|
||||||
|
# 全文允许偏离目标字数的误差带(用户要求「误差不超过 100 字」):初稿装配后双向收敛
|
||||||
|
# 到 [target-tol, target+tol] 内,太短就扩写、太长就压缩。
|
||||||
|
_WORD_COUNT_TOLERANCE = 100
|
||||||
|
# 单节字数下限:均分/压缩时给章节兜个底,避免某节被压成一两句话。
|
||||||
|
_SECTION_MIN_WORDS = 80
|
||||||
|
# 收敛回合上限:每回合调一节(按相对应得字数偏差最大的节优先)。放宽到 8,让大幅超量
|
||||||
|
# (需逐节压多节)也能压到位;命中容差或所有节都压不动即提前停。
|
||||||
|
_MAX_REBALANCE_ROUNDS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_section_words(word_count_target: int, n_sections: int) -> int:
|
||||||
|
"""把总目标字数均分到各章节。
|
||||||
|
|
||||||
|
历史实现按 1.1 过量系数放大以补偿模型「写不够」,但实测模型更常「写太多」
|
||||||
|
(800 字写成 2000)。这里去掉放大、按目标如实均分,只留一个保底下限防止单节退化;
|
||||||
|
初稿超量/欠量统一交给装配后的双向收敛阶段纠正,确保全文落在误差带内。
|
||||||
|
"""
|
||||||
|
n = max(n_sections, 1)
|
||||||
|
return max(_SECTION_MIN_WORDS, round(word_count_target / n))
|
||||||
|
|
||||||
|
|
||||||
|
def _section_word_range(per_section: int) -> tuple[int, int]:
|
||||||
|
"""单节目标对应的「硬上下限」,喂给模型卡字数。
|
||||||
|
|
||||||
|
关键是给出**上限**——旧提示词只说「不少于 N 字」,模型逐节超额累加才把 800 写成 2000。
|
||||||
|
"""
|
||||||
|
return int(per_section * 0.85), int(per_section * 1.2)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_rebalance(
|
||||||
|
counts: list[int], fair: int, target: int, tol: int, skip: set[int] | tuple = ()
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
"""决定下一步调整哪一节、调到多少字;全文已在容差内或无可调项则返回 None。
|
||||||
|
|
||||||
|
返回 ``(idx, sec_target)``:
|
||||||
|
- 全文偏长 → 选「超应得份额最多」且未跳过的节,**压到它的应得份额**(可达目标,不强求
|
||||||
|
一节吃下全部缺口;各超额节轮流压到份额,累计即收敛到目标)。没有超份额的节时,退而
|
||||||
|
把最长的一节按缺口压一压。
|
||||||
|
- 全文偏短 → 选「最欠份额」的节,把整体缺口加到它头上扩写(扩写对模型更容易)。
|
||||||
|
``skip`` 是本轮已尝试但「压不动 / 没改善」的章节集合,跳过它们换下一节,避免一节压不动
|
||||||
|
就放弃整个收敛。配合调用方「确有改善才采用」的护栏,保证单调收敛、自然终止。
|
||||||
|
"""
|
||||||
|
if not counts:
|
||||||
|
return None
|
||||||
|
total = sum(counts)
|
||||||
|
delta = target - total # >0 需补,<0 需删
|
||||||
|
if abs(delta) <= tol:
|
||||||
|
return None
|
||||||
|
pool = [k for k in range(len(counts)) if k not in skip]
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
if delta < 0:
|
||||||
|
over = [k for k in pool if counts[k] > fair]
|
||||||
|
if over:
|
||||||
|
idx = max(over, key=lambda k: counts[k] - fair)
|
||||||
|
return idx, max(_SECTION_MIN_WORDS, fair)
|
||||||
|
# 各节都不高于均分却仍超目标(节数偏多 / 下限抬高)→ 压最长的一节
|
||||||
|
idx = max(pool, key=lambda k: counts[k])
|
||||||
|
return idx, max(_SECTION_MIN_WORDS, counts[idx] + delta)
|
||||||
|
under = [k for k in pool if counts[k] < fair]
|
||||||
|
target_pool = under or pool
|
||||||
|
idx = min(target_pool, key=lambda k: counts[k] - fair)
|
||||||
|
return idx, counts[idx] + delta
|
||||||
|
|
||||||
|
|
||||||
|
# 触发「先改大纲再重写正文」的修订动作(直接按用户意见改 / 采纳编辑意见 / 自定义修订)
|
||||||
|
_REVISE_ACTIONS = {"user_revise", "accept_review", "custom_revise"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _revise_outline_with_notes(
|
||||||
|
state: AIWritingState, config: RunnableConfig, outline: dict, feedback: str
|
||||||
|
) -> dict:
|
||||||
|
"""修订模式下,先按用户/编辑意见更新大纲(标题/结构),再据此重写正文。
|
||||||
|
|
||||||
|
章节标题、文章标题都由大纲注入,正文生成本身不产出标题——所以「把第一章标题改成…」
|
||||||
|
「加一节 / 删一节 / 调整顺序」这类指令必须先落到大纲,成稿里才看得到变化(否则只是
|
||||||
|
正文被重写、标题仍沿用旧大纲)。复用 OUTLINE_REVISE 提示词:它只改用户明确提到的
|
||||||
|
部分,纯正文类意见(如「第二段数据来源不清晰」)不会误动结构。任何异常都保留原大纲。
|
||||||
|
"""
|
||||||
|
pkg = state.get("material_package") or {}
|
||||||
|
model_name = (state.get("model_name") or "").strip() or None
|
||||||
|
try:
|
||||||
|
result = await llm_json(
|
||||||
|
OUTLINE_REVISE_SYSTEM,
|
||||||
|
OUTLINE_REVISE_USER.format(
|
||||||
|
user_intent=state.get("user_intent", ""),
|
||||||
|
article_type=state.get("article_type", "analysis"),
|
||||||
|
target_audience=state.get("target_audience", "普通读者"),
|
||||||
|
word_count_target=state.get("word_count_target", 800),
|
||||||
|
current_outline=json.dumps(outline, ensure_ascii=False, indent=2),
|
||||||
|
feedback=feedback,
|
||||||
|
materials_summary=_revise_materials_summary(pkg),
|
||||||
|
),
|
||||||
|
outline, # 解析失败 → 回退原大纲
|
||||||
|
config,
|
||||||
|
model_name=model_name,
|
||||||
|
)
|
||||||
|
return _revise_parse_outline(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("draft revise: outline update failed, keep original outline: %s", e)
|
||||||
|
return outline
|
||||||
|
|
||||||
|
|
||||||
def _normalize_section_headings(text: str) -> str:
|
def _normalize_section_headings(text: str) -> str:
|
||||||
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
|
"""把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。
|
||||||
@@ -117,19 +228,112 @@ def _looks_like_refusal(text: str) -> bool:
|
|||||||
return any(marker in text for marker in _REFUSAL_MARKERS)
|
return any(marker in text for marker in _REFUSAL_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
def _get_section_materials(material_ids: list[str], pkg: dict) -> str:
|
def _build_citation_map(pkg: dict) -> dict[str, int]:
|
||||||
|
"""material_id → 1-based 序号(按 package 顺序),写作时作为角标 [n] 的稳定编号。
|
||||||
|
|
||||||
|
成稿装配后再按「正文出现顺序」重排成连续 [1][2][3](见 ``_append_references``)。
|
||||||
|
"""
|
||||||
|
return {m["id"]: i + 1 for i, m in enumerate(pkg.get("materials", [])) if m.get("id")}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_section_materials(material_ids: list[str], pkg: dict, cite_map: dict[str, int] | None = None) -> str:
|
||||||
|
"""渲染本节可用素材喂给作家。
|
||||||
|
|
||||||
|
``cite_map`` 非空(开启参考文献)时,每条素材前缀用其**全局引用编号** ``[n]``,作家据此在
|
||||||
|
正文里打角标;为空时沿用旧行为(前缀素材 id,仅作辨识、不要求引用)。
|
||||||
|
"""
|
||||||
mat_map = {m["id"]: m for m in pkg.get("materials", [])}
|
mat_map = {m["id"]: m for m in pkg.get("materials", [])}
|
||||||
|
|
||||||
|
def _prefix(mid: str) -> str:
|
||||||
|
if cite_map and mid in cite_map:
|
||||||
|
return f"[{cite_map[mid]}]"
|
||||||
|
return f"[{mid}]"
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for mid in material_ids:
|
for mid in material_ids:
|
||||||
if mid in mat_map:
|
if mid in mat_map:
|
||||||
m = mat_map[mid]
|
m = mat_map[mid]
|
||||||
lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}")
|
lines.append(f"{_prefix(mid)} {m['title']}: {m['content'][:300]}")
|
||||||
if not lines:
|
if not lines:
|
||||||
for m in pkg.get("materials", [])[:2]:
|
for m in pkg.get("materials", [])[:2]:
|
||||||
lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}")
|
lines.append(f"{_prefix(m['id'])} {m['title']}: {m['content'][:300]}")
|
||||||
return "\n".join(lines) if lines else "(无相关素材)"
|
return "\n".join(lines) if lines else "(无相关素材)"
|
||||||
|
|
||||||
|
|
||||||
|
# 开启参考文献时,追加到每节写作指令末尾:要求作家在引用素材处打角标。
|
||||||
|
_CITATION_DIRECTIVE = (
|
||||||
|
"\n\n【引用要求】上面每条素材前的 [n] 是它的编号。"
|
||||||
|
"当你在正文里用到某条素材的事实/数据/观点时,在该句句末加上对应角标,如 [1] 或 [1][3];"
|
||||||
|
"只能使用确实给出的素材编号,不要编造编号,不要给通用常识加角标。"
|
||||||
|
"不要在本节末尾自行罗列参考文献——文末的「参考文献」列表由系统统一生成。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CITATION_TOKEN_RE = re.compile(r"\[(\d{1,3})\]")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_reference_entry(m: dict) -> str:
|
||||||
|
"""单条参考文献的展示文本:标题. 来源. 日期. url(缺项自动省略)。"""
|
||||||
|
title = (m.get("title") or "").strip() or "(无标题)"
|
||||||
|
parts = [title]
|
||||||
|
source = (m.get("source") or "").strip()
|
||||||
|
if source:
|
||||||
|
parts.append(source)
|
||||||
|
date = (m.get("published_at") or "").strip()
|
||||||
|
if date:
|
||||||
|
parts.append(date)
|
||||||
|
entry = ". ".join(parts)
|
||||||
|
url = (m.get("url") or "").strip()
|
||||||
|
if url:
|
||||||
|
entry = f"{entry}. {url}"
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _append_references(markdown: str, pkg: dict, cite_map: dict[str, int]) -> str:
|
||||||
|
"""把正文里的引用角标按「出现顺序」重排为连续 [1][2][3],并在文末追加「参考文献」列表。
|
||||||
|
|
||||||
|
只收录正文真正引用到(且编号有效)的素材,列表顺序与正文出现顺序一致——像论文一样。
|
||||||
|
没有任何有效引用时原样返回(不加空列表)。
|
||||||
|
"""
|
||||||
|
num_to_mat: dict[int, dict] = {}
|
||||||
|
for m in pkg.get("materials", []):
|
||||||
|
n = cite_map.get(m.get("id"))
|
||||||
|
if n:
|
||||||
|
num_to_mat[n] = m
|
||||||
|
if not num_to_mat:
|
||||||
|
return markdown
|
||||||
|
|
||||||
|
# 1) 按正文出现顺序收集被引用且有效的原始编号
|
||||||
|
order: list[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for match in _CITATION_TOKEN_RE.finditer(markdown):
|
||||||
|
k = int(match.group(1))
|
||||||
|
if k in num_to_mat and k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
order.append(k)
|
||||||
|
if not order:
|
||||||
|
return markdown
|
||||||
|
|
||||||
|
# 2) 原始编号 → 连续新编号(出现先后);逐 match 独立映射,避免顺序替换串号
|
||||||
|
renumber = {old: new for new, old in enumerate(order, start=1)}
|
||||||
|
|
||||||
|
def _sub(match: re.Match) -> str:
|
||||||
|
k = int(match.group(1))
|
||||||
|
return f"[{renumber[k]}]" if k in renumber else match.group(0)
|
||||||
|
|
||||||
|
body = _CITATION_TOKEN_RE.sub(_sub, markdown)
|
||||||
|
|
||||||
|
# 3) 文末参考文献列表(按新编号顺序)。
|
||||||
|
# 每条之间用**空行**分隔,使其各成独立段落——否则 Markdown 会把单换行的多行并成一段,
|
||||||
|
# 所有文献会挤成一坨(见排版反馈)。与正文一致沿用 [n] 角标,不再叠加列表序号。
|
||||||
|
entries = [
|
||||||
|
f"[{renumber[old]}] {_format_reference_entry(num_to_mat[old])}"
|
||||||
|
for old in order
|
||||||
|
]
|
||||||
|
references_md = "## 参考文献\n\n" + "\n\n".join(entries)
|
||||||
|
return f"{body}\n\n{references_md}"
|
||||||
|
|
||||||
|
|
||||||
async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> None:
|
async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> None:
|
||||||
"""把 current_draft 异步落到业务表(best-effort,写失败不影响流程)。
|
"""把 current_draft 异步落到业务表(best-effort,写失败不影响流程)。
|
||||||
|
|
||||||
@@ -171,6 +375,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
intervention = state.get("last_user_intervention") or {}
|
intervention = state.get("last_user_intervention") or {}
|
||||||
revision_count = state.get("revision_count", 0)
|
revision_count = state.get("revision_count", 0)
|
||||||
|
|
||||||
|
# 参考文献:开启且有素材时,写作给素材编号 + 要求作家打角标 [n],成稿装配后统一生成文献列表。
|
||||||
|
add_references = bool(state.get("add_references", False)) and bool(pkg.get("materials"))
|
||||||
|
cite_map = _build_citation_map(pkg) if add_references else None
|
||||||
|
|
||||||
# 写作模式;用户在「素材不足求助」里选「按通用知识续写」的章节强制走宽松
|
# 写作模式;用户在「素材不足求助」里选「按通用知识续写」的章节强制走宽松
|
||||||
writing_mode = state.get("writing_mode", "loose")
|
writing_mode = state.get("writing_mode", "loose")
|
||||||
loose_titles: set[str] = set(intervention.get("_loose_sections") or [])
|
loose_titles: set[str] = set(intervention.get("_loose_sections") or [])
|
||||||
@@ -198,11 +406,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
if parts:
|
if parts:
|
||||||
revision_notes = "\n修改要求:\n" + "\n".join(parts)
|
revision_notes = "\n修改要求:\n" + "\n".join(parts)
|
||||||
|
|
||||||
|
# 修订模式:先把上面这条意见落实到大纲(标题/结构),再据更新后的大纲逐节重写。
|
||||||
|
# 章节标题、文章标题都由大纲注入、正文生成本身不产出标题,所以「改第一章标题」「加一节/
|
||||||
|
# 删一节」这类指令必须先改大纲才能在成稿里生效(否则只是正文被重写、标题仍是旧的)。
|
||||||
|
outline_revised = False
|
||||||
|
if intervention.get("action") in _REVISE_ACTIONS and revision_notes.strip():
|
||||||
|
revised_outline = await _revise_outline_with_notes(state, config, outline, revision_notes.strip())
|
||||||
|
if revised_outline and revised_outline != outline:
|
||||||
|
outline = revised_outline
|
||||||
|
outline_revised = True
|
||||||
|
|
||||||
word_count_target = state.get("word_count_target", 800)
|
word_count_target = state.get("word_count_target", 800)
|
||||||
sections = outline.get("sections", [])
|
sections = outline.get("sections", [])
|
||||||
n_sections = max(len(sections), 1)
|
n_sections = max(len(sections), 1)
|
||||||
# 单节硬下限:总目标 / 节数,乘 1.1 过量系数补偿模型对短目标的天然欠量;单节至少 150 字
|
# 按目标如实均分到各节(不再放大),并给模型一个硬上下限区间卡字数;
|
||||||
words_per_section = max(150, int(word_count_target * 1.1 / n_sections))
|
# 装配后再双向收敛把全文夹逼进误差带。
|
||||||
|
words_per_section = _allocate_section_words(word_count_target, n_sections)
|
||||||
|
sec_min_words, sec_max_words = _section_word_range(words_per_section)
|
||||||
article_title = outline.get("title", "文章")
|
article_title = outline.get("title", "文章")
|
||||||
|
|
||||||
# 兼容后端 snake_case 与前端编辑后回传的 camelCase
|
# 兼容后端 snake_case 与前端编辑后回传的 camelCase
|
||||||
@@ -265,7 +485,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
key_points = section.get("key_points") or section.get("keyPoints") or []
|
key_points = section.get("key_points") or section.get("keyPoints") or []
|
||||||
mat_ids = section.get("material_ids") or section.get("materialIds") or []
|
mat_ids = section.get("material_ids") or section.get("materialIds") or []
|
||||||
|
|
||||||
section_materials = _get_section_materials(mat_ids, pkg)
|
section_materials = _get_section_materials(mat_ids, pkg, cite_map)
|
||||||
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
|
# 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。
|
||||||
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
|
# 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。
|
||||||
key_points_str = _render_key_points_for_prompt(key_points)
|
key_points_str = _render_key_points_for_prompt(key_points)
|
||||||
@@ -289,7 +509,12 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
revision_section=revision_notes,
|
revision_section=revision_notes,
|
||||||
mode_section=mode_section,
|
mode_section=mode_section,
|
||||||
target_words=words_per_section,
|
target_words=words_per_section,
|
||||||
|
min_words=sec_min_words,
|
||||||
|
max_words=sec_max_words,
|
||||||
)
|
)
|
||||||
|
# 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。
|
||||||
|
if add_references:
|
||||||
|
user_msg += _CITATION_DIRECTIVE
|
||||||
|
|
||||||
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
|
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
|
||||||
section_succeeded = False
|
section_succeeded = False
|
||||||
@@ -440,61 +665,95 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _assemble() -> str:
|
def _assemble(bufs: list[str]) -> str:
|
||||||
# 章节正文里的标题统一归一到 ###(要点小标题),章节标题用 ##,文章标题用 #
|
# 章节正文里的标题统一归一到 ###(要点小标题),章节标题用 ##,文章标题用 #
|
||||||
body = "\n\n".join(
|
body = "\n\n".join(
|
||||||
f"## {t}\n\n{_normalize_section_headings(b)}"
|
f"## {t}\n\n{_normalize_section_headings(b)}"
|
||||||
for t, b in zip(section_titles, section_bufs)
|
for t, b in zip(section_titles, bufs)
|
||||||
)
|
)
|
||||||
return f"# {article_title}\n\n{body}"
|
return f"# {article_title}\n\n{body}"
|
||||||
|
|
||||||
full_markdown = _assemble()
|
async def _rewrite_section(idx: int, sec_target: int, *, shrink: bool) -> str:
|
||||||
|
"""重写第 idx 节使其趋近 sec_target 字:shrink=True 走压缩、否则扩写(单轮,非流式)。
|
||||||
|
|
||||||
|
兜底优化,不再做模型链容错:拿当前已成功的模型试一次即可,失败由调用方保留原文。
|
||||||
|
"""
|
||||||
|
sec = sections[idx]
|
||||||
|
tmpl = DRAFT_COMPRESS_USER if shrink else DRAFT_EXPAND_USER
|
||||||
|
msg = tmpl.format(
|
||||||
|
article_title=article_title,
|
||||||
|
section_title=section_titles[idx],
|
||||||
|
key_points=_render_key_points_for_prompt(
|
||||||
|
sec.get("key_points") or sec.get("keyPoints") or []
|
||||||
|
),
|
||||||
|
section_materials=_get_section_materials(
|
||||||
|
sec.get("material_ids") or sec.get("materialIds") or [], pkg, cite_map
|
||||||
|
),
|
||||||
|
existing_text=section_bufs[idx],
|
||||||
|
target_words=sec_target,
|
||||||
|
)
|
||||||
|
# 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。
|
||||||
|
if add_references:
|
||||||
|
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
||||||
|
if use_agent:
|
||||||
|
return (await stream_writing_agent(
|
||||||
|
agent_id=WRITER_AGENT_ID,
|
||||||
|
user_payload=msg,
|
||||||
|
state=state,
|
||||||
|
config=config,
|
||||||
|
on_text=None,
|
||||||
|
)).strip()
|
||||||
|
rewrite_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
|
||||||
|
resp = await rewrite_model.ainvoke([
|
||||||
|
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
||||||
|
HumanMessage(content=msg),
|
||||||
|
])
|
||||||
|
parser = ThinkTagParser()
|
||||||
|
return "".join(
|
||||||
|
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
full_markdown = _assemble(section_bufs)
|
||||||
word_count = count_words(full_markdown)
|
word_count = count_words(full_markdown)
|
||||||
|
|
||||||
# 扩写兜底:总字数不足目标 90% 时,重写并扩充最短的章节(单轮,非流式)
|
# 双向收敛兜底:把全文字数夹逼进 [target±tol]。太短就扩写最欠的节、太长就压缩最超的节,
|
||||||
if section_bufs and word_count < word_count_target * 0.9:
|
# 每回合只动一节,且仅在「确实更接近目标」时才采用。某节压不动 / 没改善 → 记入 skip 换
|
||||||
idx = min(range(len(section_bufs)), key=lambda k: count_words(section_bufs[k]))
|
# 下一节继续(不再一节失败就放弃整个收敛,否则弱模型一次压缩失败就留下超量稿)。
|
||||||
target_sec = sections[idx]
|
rounds = 0
|
||||||
expand_target = count_words(section_bufs[idx]) + (word_count_target - word_count)
|
skip: set[int] = set()
|
||||||
|
while section_bufs and rounds < _MAX_REBALANCE_ROUNDS:
|
||||||
|
counts = [count_words(b) for b in section_bufs]
|
||||||
|
pick = _pick_rebalance(counts, words_per_section, word_count_target, _WORD_COUNT_TOLERANCE, skip)
|
||||||
|
if pick is None:
|
||||||
|
break
|
||||||
|
rounds += 1
|
||||||
|
idx, sec_target = pick
|
||||||
|
shrink = sec_target < counts[idx]
|
||||||
try:
|
try:
|
||||||
expand_msg = DRAFT_EXPAND_USER.format(
|
new_text = await _rewrite_section(idx, sec_target, shrink=shrink)
|
||||||
article_title=article_title,
|
|
||||||
section_title=section_titles[idx],
|
|
||||||
key_points=_render_key_points_for_prompt(
|
|
||||||
target_sec.get("key_points") or target_sec.get("keyPoints") or []
|
|
||||||
),
|
|
||||||
section_materials=_get_section_materials(
|
|
||||||
target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg
|
|
||||||
),
|
|
||||||
existing_text=section_bufs[idx],
|
|
||||||
target_words=expand_target,
|
|
||||||
)
|
|
||||||
if use_agent:
|
|
||||||
expanded = (await stream_writing_agent(
|
|
||||||
agent_id=WRITER_AGENT_ID,
|
|
||||||
user_payload=expand_msg,
|
|
||||||
state=state,
|
|
||||||
config=config,
|
|
||||||
on_text=None,
|
|
||||||
)).strip()
|
|
||||||
else:
|
|
||||||
# 扩写是兜底优化,不再做模型链兜底:拿当前已成功的模型试一次即可,失败就保留原文。
|
|
||||||
expand_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
|
|
||||||
resp = await expand_model.ainvoke([
|
|
||||||
SystemMessage(content=DRAFT_SECTION_SYSTEM),
|
|
||||||
HumanMessage(content=expand_msg),
|
|
||||||
])
|
|
||||||
parser = ThinkTagParser()
|
|
||||||
expanded = "".join(
|
|
||||||
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
|
|
||||||
).strip()
|
|
||||||
# 仅在扩写确实更长时才采用,避免越改越短
|
|
||||||
if count_words(expanded) > count_words(section_bufs[idx]):
|
|
||||||
section_bufs[idx] = expanded
|
|
||||||
full_markdown = _assemble()
|
|
||||||
word_count = count_words(full_markdown)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("draft expand failed for section [%s]: %s", section_titles[idx], e)
|
logger.warning("draft rebalance failed for section [%s]: %s", section_titles[idx], e)
|
||||||
|
skip.add(idx)
|
||||||
|
continue
|
||||||
|
if not new_text:
|
||||||
|
skip.add(idx)
|
||||||
|
continue
|
||||||
|
candidate = list(section_bufs)
|
||||||
|
candidate[idx] = new_text
|
||||||
|
new_md = _assemble(candidate)
|
||||||
|
new_wc = count_words(new_md)
|
||||||
|
if abs(new_wc - word_count_target) < abs(word_count - word_count_target):
|
||||||
|
section_bufs = candidate
|
||||||
|
full_markdown = new_md
|
||||||
|
word_count = new_wc
|
||||||
|
skip.discard(idx) # 改善了,后续若仍最超可再压
|
||||||
|
else:
|
||||||
|
skip.add(idx) # 这一节压不动 / 反而更差,跳过它,换下一节
|
||||||
|
|
||||||
|
# 参考文献:在全文(含字数收敛)定稿后统一处理——把正文角标按出现顺序重排为连续 [1][2][3],
|
||||||
|
# 并在文末追加「参考文献」列表(只收录被引用到的素材)。word_count 保持正文字数、不计文献列表。
|
||||||
|
if add_references and cite_map:
|
||||||
|
full_markdown = _append_references(full_markdown, pkg, cite_map)
|
||||||
|
|
||||||
draft = DraftArticle(
|
draft = DraftArticle(
|
||||||
title=outline.get("title", "文章"),
|
title=outline.get("title", "文章"),
|
||||||
@@ -513,6 +772,13 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
notices: list[dict] = []
|
notices: list[dict] = []
|
||||||
if model_default_notice:
|
if model_default_notice:
|
||||||
notices.append(model_default_notice)
|
notices.append(model_default_notice)
|
||||||
|
if outline_revised:
|
||||||
|
notices.append({
|
||||||
|
"type": "notice",
|
||||||
|
"agent_name": agent_label,
|
||||||
|
"title": "已按意见调整大纲",
|
||||||
|
"detail": "已根据你的修改意见更新大纲(如标题/结构),并据此重写正文。",
|
||||||
|
})
|
||||||
if section_errors:
|
if section_errors:
|
||||||
notices.append({
|
notices.append({
|
||||||
"type": "notice",
|
"type": "notice",
|
||||||
@@ -521,7 +787,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
|
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
updates: dict = {
|
||||||
"current_draft": draft,
|
"current_draft": draft,
|
||||||
"drafts": [draft],
|
"drafts": [draft],
|
||||||
"status": "awaiting_draft_confirm",
|
"status": "awaiting_draft_confirm",
|
||||||
@@ -539,3 +805,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
# 大纲被意见改过 → 同步回 state,使后续修订/确认基于更新后的大纲(标题不再回退)
|
||||||
|
if outline_revised:
|
||||||
|
updates["current_outline"] = outline
|
||||||
|
return updates
|
||||||
+6
-1
@@ -271,9 +271,14 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
|||||||
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
|
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
|
||||||
use_agent = False
|
use_agent = False
|
||||||
|
|
||||||
|
# 新(列表外)文章类型时附上配置助手识别的一句话特征,让大纲生成体现该类型侧重;
|
||||||
|
# 命中已有类型时 detail 为空,article_type 维持原样。
|
||||||
|
_article_type = state.get("article_type", "analysis")
|
||||||
|
_article_type_detail = (state.get("article_type_detail") or "").strip()
|
||||||
|
article_type_text = f"{_article_type}({_article_type_detail})" if _article_type_detail else _article_type
|
||||||
fmt_args = dict(
|
fmt_args = dict(
|
||||||
user_intent=state["user_intent"],
|
user_intent=state["user_intent"],
|
||||||
article_type=state.get("article_type", "analysis"),
|
article_type=article_type_text,
|
||||||
target_audience=state.get("target_audience", "普通读者"),
|
target_audience=state.get("target_audience", "普通读者"),
|
||||||
word_count_target=state.get("word_count_target", 800),
|
word_count_target=state.get("word_count_target", 800),
|
||||||
materials_summary=_materials_summary(pkg),
|
materials_summary=_materials_summary(pkg),
|
||||||
|
|||||||
+12
-3
@@ -24,8 +24,17 @@ EDITOR_SYSTEM = """你是一名资深主编(编辑),标准极高,要求
|
|||||||
]
|
]
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
# 用户在提交编辑审核时附带的修改意见 / 关注点(无则为空串,不注入)。
|
||||||
|
# 放在「判断标准」之后、待审文章之前,提醒编辑在本维度重点核查用户在意的方面。
|
||||||
|
USER_FOCUS_BLOCK = """
|
||||||
|
|
||||||
|
【用户特别关注(高优先级)】
|
||||||
|
用户在提交审核时提出了以下修改意见 / 关注点,请在本维度审查时**重点核查**这些方面;
|
||||||
|
若发现文章未妥善处理,必须在 issues 中明确列出对应问题并给出针对性的 suggestion:
|
||||||
|
{notes}"""
|
||||||
|
|
||||||
FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
|
FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
|
||||||
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。
|
判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。{user_focus}
|
||||||
|
|
||||||
参考素材:
|
参考素材:
|
||||||
{materials_summary}
|
{materials_summary}
|
||||||
@@ -36,7 +45,7 @@ FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。
|
|||||||
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
|
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
|
||||||
|
|
||||||
LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
|
LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
|
||||||
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。
|
判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。{user_focus}
|
||||||
|
|
||||||
待审文章:
|
待审文章:
|
||||||
{article}
|
{article}
|
||||||
@@ -44,7 +53,7 @@ LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。
|
|||||||
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
|
只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):"""
|
||||||
|
|
||||||
LANGUAGE_REVIEW_USER = """请审查以下文章的【语言文字】维度。
|
LANGUAGE_REVIEW_USER = """请审查以下文章的【语言文字】维度。
|
||||||
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。
|
判断标准:语言是否流畅,用词是否准确,有无错别字或病句。{user_focus}
|
||||||
|
|
||||||
待审文章:
|
待审文章:
|
||||||
{article}
|
{article}
|
||||||
|
|||||||
+22
-3
@@ -208,7 +208,8 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
|
|||||||
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
|
- 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。
|
||||||
- 不要写"以下是正文""本段内容如下"之类的开场白。
|
- 不要写"以下是正文""本段内容如下"之类的开场白。
|
||||||
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
|
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
|
||||||
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。"""
|
- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理。
|
||||||
|
- 【字数纪律,硬性要求】严格遵守用户给定的字数区间:既不要明显写不够,更不要超出上限。靠控制论述详略来卡字数,不要为凑字数注水,也不要为收尾而草草带过。"""
|
||||||
|
|
||||||
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
|
# ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ───────────────────
|
||||||
|
|
||||||
@@ -240,14 +241,14 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
|
|||||||
相关素材:
|
相关素材:
|
||||||
{section_materials}
|
{section_materials}
|
||||||
{revision_section}{mode_section}
|
{revision_section}{mode_section}
|
||||||
请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。
|
请写出「{section_title}」这一章节的内容,目标约 {target_words} 字,并**严格控制在 {min_words}~{max_words} 字之间**——不要明显少于下限,更绝不要超过上限。
|
||||||
|
|
||||||
按下面的结构两层展开写:
|
按下面的结构两层展开写:
|
||||||
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
|
- 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。
|
||||||
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
|
- 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。
|
||||||
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
|
- 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。
|
||||||
|
|
||||||
论述充分、层次清晰、内容充实。"""
|
论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。"""
|
||||||
|
|
||||||
DRAFT_EXPAND_USER = """文章标题:{article_title}
|
DRAFT_EXPAND_USER = """文章标题:{article_title}
|
||||||
当前章节:{section_title}
|
当前章节:{section_title}
|
||||||
@@ -266,3 +267,21 @@ DRAFT_EXPAND_USER = """文章标题:{article_title}
|
|||||||
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
|
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
|
||||||
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
|
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
|
||||||
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
|
- 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
|
||||||
|
|
||||||
|
DRAFT_COMPRESS_USER = """文章标题:{article_title}
|
||||||
|
当前章节:{section_title}
|
||||||
|
章节内的小节与子要点(层级 bullet 列表):
|
||||||
|
{key_points}
|
||||||
|
|
||||||
|
相关素材:
|
||||||
|
{section_materials}
|
||||||
|
|
||||||
|
这一章节已有内容如下:
|
||||||
|
{existing_text}
|
||||||
|
|
||||||
|
以上内容篇幅明显偏长。请在保留原意、关键事实和「### 小节 / #### 子要点」两级标题结构的基础上**大幅精简**改写这一章节,使内容**压到 {target_words} 字以内**——这是硬性上限,宁可删减细节也绝不能超过。
|
||||||
|
要求:
|
||||||
|
- 这是「压缩」任务,不是「重写扩充」:必须比原文明显更短。大胆删减冗余表述、合并重复论点、砍掉次要细节与铺陈,只保留关键事实、数据与核心结论。
|
||||||
|
- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
|
||||||
|
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
|
||||||
|
- 直接返回精简后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。"""
|
||||||
@@ -129,12 +129,17 @@ class AIWritingState(TypedDict):
|
|||||||
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
|
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
|
||||||
user_outline_raw: str
|
user_outline_raw: str
|
||||||
article_type: str
|
article_type: str
|
||||||
|
# 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。
|
||||||
|
# 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。
|
||||||
|
article_type_detail: str
|
||||||
target_audience: str
|
target_audience: str
|
||||||
word_count_target: int
|
word_count_target: int
|
||||||
# 素材检索关键词数量(用户指定,1~10,默认 6)
|
# 素材检索关键词数量(用户指定,1~10,默认 6)
|
||||||
keyword_count: int
|
keyword_count: int
|
||||||
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
|
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
|
||||||
writing_mode: str
|
writing_mode: str
|
||||||
|
# 是否在成稿里加参考文献:正文引用素材处加角标 [n],文末按出现顺序生成「参考文献」列表(像论文)。
|
||||||
|
add_references: bool
|
||||||
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
|
# 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/
|
||||||
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
|
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
|
||||||
# 节点按 general 兼容处理。
|
# 节点按 general 兼容处理。
|
||||||
|
|||||||
+3
-1
@@ -27,7 +27,9 @@ from deerflow.models import create_chat_model
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
|
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
|
||||||
WRITING_SETUP_MAX_ROUNDS = 3
|
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
|
||||||
|
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
|
||||||
|
WRITING_SETUP_MAX_ROUNDS = 8
|
||||||
|
|
||||||
|
|
||||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||||
|
|||||||
+23
-12
@@ -1128,29 +1128,40 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
|
|||||||
type_lines = "\n".join(
|
type_lines = "\n".join(
|
||||||
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
|
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
|
||||||
)
|
)
|
||||||
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
|
type_block = (
|
||||||
|
"【文章类型】优先从下列已有类型里选,命中就把它的 key 填进 `article_type`:\n"
|
||||||
|
f"{type_lines}\n"
|
||||||
|
"若上述类型**都不贴切**,可以**自拟一个新的文章类型**:`article_type` 填一个简短的中文类型名"
|
||||||
|
"(如「政策解读」「人物特写」「复盘总结」),并在 `article_type_detail` 里用一句话说明该类型的"
|
||||||
|
"写作侧重/特征。能用已有类型就不要新造,确实不贴切才新建。"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
|
type_block = (
|
||||||
|
"【文章类型】根据意图判断文章类型:`article_type` 填一个简短的中文类型名,并在 "
|
||||||
|
"`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。"
|
||||||
|
)
|
||||||
|
|
||||||
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
|
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
|
||||||
|
|
||||||
【铁律——最高优先级】
|
【铁律——最高优先级】
|
||||||
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
|
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
|
||||||
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
|
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
|
||||||
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
|
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
|
||||||
|
|
||||||
【工作流程】(与用户交互确认,但最多 3 轮内必须提交)
|
【工作流程】
|
||||||
1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。
|
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
|
||||||
2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。
|
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
|
||||||
3. 调用 `setup_writing` 提交配置:
|
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
|
||||||
|
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:
|
||||||
- `user_intent`:精炼成一句话的写作目标(必填)。
|
- `user_intent`:精炼成一句话的写作目标(必填)。
|
||||||
- `article_type`:见下方可选类型;不确定就留空。
|
- `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。
|
||||||
|
- `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。
|
||||||
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
|
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
|
||||||
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
|
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
|
||||||
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
|
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
|
||||||
- `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。
|
- `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。
|
||||||
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
|
- `outline`:**填入刚刚与用户确认的那份大纲全文**(保留其层级与标题)。
|
||||||
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
|
调用前用**一句话**说明将提交的配置要点;调用之后不再输出任何内容。
|
||||||
|
|
||||||
{type_block}
|
{type_block}
|
||||||
"""
|
"""
|
||||||
|
|||||||
+2
@@ -74,6 +74,8 @@ class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlew
|
|||||||
return {
|
return {
|
||||||
"user_intent": str(args.get("user_intent") or "").strip(),
|
"user_intent": str(args.get("user_intent") or "").strip(),
|
||||||
"article_type": str(args.get("article_type") or "").strip(),
|
"article_type": str(args.get("article_type") or "").strip(),
|
||||||
|
# 新(列表外)文章类型的一句话特征说明;命中已有类型时为空。
|
||||||
|
"article_type_detail": str(args.get("article_type_detail") or "").strip(),
|
||||||
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
|
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
|
||||||
"target_audience": str(args.get("target_audience") or "").strip(),
|
"target_audience": str(args.get("target_audience") or "").strip(),
|
||||||
"writing_mode": str(args.get("writing_mode") or "").strip(),
|
"writing_mode": str(args.get("writing_mode") or "").strip(),
|
||||||
|
|||||||
+9
-3
@@ -28,24 +28,30 @@ def setup_writing(
|
|||||||
writing_mode: str | None = None,
|
writing_mode: str | None = None,
|
||||||
material_source: str | None = None,
|
material_source: str | None = None,
|
||||||
outline: str | None = None,
|
outline: str | None = None,
|
||||||
|
article_type_detail: str | None = None,
|
||||||
) -> Command:
|
) -> Command:
|
||||||
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
|
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
|
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
|
||||||
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
|
article_type: 文章类型。优先填系统给定的合法类型 key;若已有类型都不贴切,可**自拟一个新的
|
||||||
|
文章类型**——此时填一个简短的中文类型名(如「政策解读」「人物特写」),并在
|
||||||
|
``article_type_detail`` 里说明该类型特征。不确定可留空。
|
||||||
word_count_target: 目标字数(正整数)。
|
word_count_target: 目标字数(正整数)。
|
||||||
target_audience: 目标读者(如「行业专家」「普通读者」)。
|
target_audience: 目标读者(如「行业专家」「普通读者」)。
|
||||||
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
|
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
|
||||||
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
|
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
|
||||||
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
|
outline: **已与用户确认的大纲全文**(保留其层级与标题)。本工具在用户确认大纲后调用,
|
||||||
|
故通常应带上这份大纲;用户未提供/未确认大纲时才留空。
|
||||||
|
article_type_detail: 仅当 ``article_type`` 是**新类型(不在已有列表里)**时填写:用一句话说明
|
||||||
|
这个类型的写作侧重/特征(如「以政策原文为锚、逐条拆解影响并给出执行建议」)。命中已有类型时留空。
|
||||||
"""
|
"""
|
||||||
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
|
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
|
||||||
return Command(
|
return Command(
|
||||||
update={
|
update={
|
||||||
"messages": [
|
"messages": [
|
||||||
ToolMessage(
|
ToolMessage(
|
||||||
content="写作配置已生成,请在右侧确认后应用到表单。",
|
content="写作配置已生成,请在右侧确认后应用到表单,或直接开始写作。",
|
||||||
tool_call_id=runtime.tool_call_id,
|
tool_call_id=runtime.tool_call_id,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in new issue
Block a user