效果优化

This commit is contained in:
董宏凯 committed 2026-05-19 18:10:15 +08:00
1 parent 2402a84c71
commit ab85e4df72
15 files changed
+548 -35

No files matched your search

+11
View File
@@ -220,6 +220,17 @@ INTRANET_SOURCE_URL_BASE + recUuid
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
若内网原文详情页地址有变,只改这一个常量即可。
### 4.5 检索到的素材全是无关内容
**现象**:素材卡片内容与写作意图完全不沾边。
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
---
## 5. 联调上线步骤
@@ -22,6 +22,7 @@ export async function startWritingSession(req: AIWritingRequest): Promise<string
word_count_target: req.wordCountTarget,
model_name: req.modelName ?? '',
keyword_count: req.keywordCount,
writing_mode: req.writingMode,
}),
});
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
@@ -106,6 +107,17 @@ function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent>
// outline
if (out.outline) out.outline = normalizeOutline(out.outline);
// blocked_sections(section_help await_user)
if (Array.isArray(out.blocked_sections)) {
out.blockedSections = out.blocked_sections.map((b: unknown) => {
const o = b as Record<string, unknown>;
return {
sectionTitle: o.section_title ?? o.sectionTitle ?? '',
reason: o.reason ?? '',
};
});
}
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
@@ -203,6 +215,10 @@ export async function resumeWriting(
user_revision_notes: payload.userRevisionNotes ?? '',
override_verdict: payload.overrideVerdict ?? false,
selected_issue_indices: payload.selectedIssueIndices ?? null,
section_decisions: (payload.sectionDecisions ?? []).map((d) => ({
section_title: d.sectionTitle,
decision: d.decision,
})),
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
@@ -37,6 +37,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [keywordCountInput, setKeywordCountInput] = useState('6');
const [strictMode, setStrictMode] = useState(false);
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
@@ -116,6 +117,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
targetAudience: audience.trim() || '普通读者',
wordCountTarget: wordCount,
keywordCount,
writingMode: strictMode ? 'strict' : 'loose',
modelName: modelName || undefined,
});
}
@@ -190,6 +192,23 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
@@ -34,6 +34,7 @@ const STATUS_LABEL: Record<string, string> = {
writing_outline: '作家规划大纲中…',
awaiting_outline_confirm: '等待确认大纲',
writing_draft: '作家写作中…',
awaiting_section_help: '等待处理素材不足的章节',
awaiting_draft_confirm: '等待确认草稿',
reviewing: '编辑审核中…',
awaiting_review_confirm: '等待处理编辑意见',
@@ -428,6 +429,11 @@ export function AIWritingPanel() {
<ReqRow label="检索关键词数量">
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
</ReqRow>
<ReqRow label="写作模式">
<span className="text-muted-foreground">
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
</span>
</ReqRow>
{state.request.modelName && (
<ReqRow label="写作模型">
<span className="text-muted-foreground">{state.request.modelName}</span>
@@ -58,6 +58,37 @@ function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDon
);
}
/** 章节正文:把 `### 要点` 行渲染成小标题,其余按段落显示(流式纯文本,非完整 markdown 解析) */
function SectionBody({ text, showCursor }: { text: string; showCursor: boolean }) {
const lines = text.split('\n');
return (
<div className="m-0">
{lines.map((line, i) => {
// 严格模式下作家声明素材不足的哨兵行,不展示给用户
if (line.includes('[[SECTION_BLOCKED]]')) return null;
const heading = /^[ \t]*#{1,6}[ \t]+/.exec(line);
if (heading) {
return (
<p key={i} className="text-xs font-semibold text-foreground mt-2 mb-1">
{line.slice(heading[0].length)}
</p>
);
}
if (!line.trim()) return null; // 跳过空行,避免大段空白
return (
<p
key={i}
className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0 mb-1"
>
{line}
</p>
);
})}
{showCursor && <TypewriterCursor />}
</div>
);
}
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
const bottomRef = useRef<HTMLDivElement>(null);
@@ -91,11 +122,11 @@ export function DraftStreamingView({ title, sections }: DraftStreamingViewProps)
{/* 该章节的思考过程(可折叠) */}
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
{/* 正文内容 */}
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
{sec.text}
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
</p>
{/* 正文内容(### 要点行渲染为小标题) */}
<SectionBody
text={sec.text}
showCursor={sec.sectionIndex === activeSectionIdx && !sec.isDone}
/>
</div>
))}
@@ -2,11 +2,14 @@ import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
BlockedSection, SectionDecisionKind,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
@@ -56,6 +59,16 @@ export function InterventionCard({
);
}
if (pausePoint === 'section_help') {
return (
<SectionHelpCard
blockedSections={pauseEvent.blockedSections ?? []}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'review_confirm') {
return (
<ReviewConfirmCard
@@ -547,3 +560,99 @@ function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }:
</div>
);
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
interface SectionHelpCardProps {
blockedSections: BlockedSection[];
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [
{ value: 'supplement', label: '补充检索素材', icon: Search },
{ value: 'loose', label: '按通用知识续写', icon: BookOpen },
{ value: 'delete', label: '删除该章节', icon: Trash2 },
];
function SectionHelpCard({ blockedSections, onSubmit, disabled }: SectionHelpCardProps) {
const [decisions, setDecisions] = useState<Record<string, SectionDecisionKind>>(
() => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])),
);
const [submitting, setSubmitting] = useState(false);
const hasSupplement = Object.values(decisions).some((d) => d === 'supplement');
async function handleSubmit() {
setSubmitting(true);
await onSubmit({
action: 'section_help',
sectionDecisions: blockedSections.map((b) => ({
sectionTitle: b.sectionTitle,
decision: decisions[b.sectionTitle] ?? 'supplement',
})),
});
setSubmitting(false);
}
return (
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
<div className="flex items-start gap-2">
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium">部分章节素材不足,需要你决定</p>
<p className="text-xs text-muted-foreground mt-0.5">
严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。
</p>
</div>
</div>
<div className="space-y-2">
{blockedSections.map((b) => (
<div key={b.sectionTitle} className="rounded-md border bg-background p-2.5 space-y-2">
<div>
<p className="text-xs font-medium">{b.sectionTitle}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
</div>
<div className="flex gap-1">
{DECISION_OPTIONS.map((opt) => {
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
disabled={disabled || submitting}
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
className={cn(
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
active
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-input text-muted-foreground hover:bg-muted/50',
)}
>
<Icon className="size-3 shrink-0" />
{opt.label}
</button>
);
})}
</div>
</div>
))}
</div>
{hasSupplement && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
</p>
)}
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={handleSubmit} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />提交并继续
</Button>
</div>
);
}
@@ -354,6 +354,11 @@ export function useAIWriting() {
else nextStatus = 'revising'; // user_revise
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
} else if (pausePoint === 'section_help') {
// 含「补充检索」→ 回检索;否则(删章节 / 通用知识续写)→ 直接回写作
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
? 'researching'
: 'writing_draft';
}
try {
@@ -25,6 +25,22 @@ export interface AIOutline {
sections: OutlineSection[];
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
/** 严格模式下素材不足、待用户决策的章节 */
export interface BlockedSection {
sectionTitle: string;
reason: string;
}
/** 单个被阻塞章节的处理决策 */
export type SectionDecisionKind = 'supplement' | 'delete' | 'loose';
export interface SectionDecision {
sectionTitle: string;
decision: SectionDecisionKind;
}
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
@@ -54,6 +70,7 @@ export type WritingStatus =
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_section_help'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
@@ -109,8 +126,10 @@ export interface ProgressEvent {
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
pauseContext?: Record<string, unknown>;
// section_help:素材不足、待用户决策的章节
blockedSections?: BlockedSection[];
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
@@ -125,6 +144,8 @@ export interface UserInterventionPayload {
overrideVerdict?: boolean;
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
selectedIssueIndices?: number[];
// section_help:每个被阻塞章节的处理决策
sectionDecisions?: SectionDecision[];
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
@@ -137,6 +158,8 @@ export interface AIWritingRequest {
modelName?: string;
/** 素材检索关键词数量(1~10,默认 6) */
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose';
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
@@ -175,7 +198,7 @@ export interface StreamingDraftSection {
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
action: string;
// material_confirm
materials?: AIMaterial[];