文章类型添加

This commit is contained in:
董宏凯 committed 2026-05-17 19:50:52 +08:00
1 parent 918e65d8d3
commit a0a6227368
14 files changed
+1089 -57

No files matched your search

@@ -0,0 +1,69 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
export interface ArticleType {
id: string;
key: string;
label: string;
description: string | null;
prompt_hint: string | null;
default_word_count: number;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CreateArticleTypeRequest {
key: string;
label: string;
description?: string;
prompt_hint?: string;
default_word_count?: number;
sort_order?: number;
}
export interface UpdateArticleTypeRequest {
key?: string;
label?: string;
description?: string | null;
prompt_hint?: string | null;
default_word_count?: number;
sort_order?: number;
}
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`;
export async function listArticleTypes(): Promise<ArticleType[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
return resp.json();
}
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(BASE(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`);
if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`);
return resp.json();
}
export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 409) throw new Error(`文章类型标识已存在`);
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`);
return resp.json();
}
export async function deleteArticleType(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`文章类型不存在`);
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
}
@@ -14,6 +14,7 @@ import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
@@ -25,22 +26,46 @@ interface ModelOption {
displayName: string;
}
const ARTICLE_TYPES: { value: AIWritingRequest['articleType']; label: string }[] = [
{ value: 'analysis', label: '分析报道' },
{ value: 'news', label: '新闻资讯' },
{ value: 'feature', label: '深度特稿' },
{ value: 'report', label: '调研报告' },
];
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] =
useState<AIWritingRequest['articleType']>('analysis');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCount, setWordCount] = useState(800);
const [wordCountInput, setWordCountInput] = useState('800');
const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
// 初始化时加载文章类型
useEffect(() => {
refreshTypes();
}, [refreshTypes]);
// 类型加载完成后:若当前选中值不在列表中则切换为第一项
useEffect(() => {
if (articleTypes.length === 0) return;
const keys = articleTypes.map((t) => t.key);
if (!keys.includes(articleType)) {
const first = articleTypes[0];
setArticleType(first.key);
setWordCountInput(String(first.default_word_count));
}
}, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps
// 切换文章类型时自动填入该类型的默认字数
function handleArticleTypeChange(key: string) {
setArticleType(key);
const found = articleTypes.find((t) => t.key === key);
if (found) {
setWordCountInput(String(found.default_word_count));
}
}
useEffect(() => {
apiFetch(`${getBackendBaseURL()}/api/models`)
.then((r) => r.json())
@@ -57,12 +82,22 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleWordCountBlur() {
const num = parseInt(wordCountInput, 10);
if (isNaN(num) || num < 100) {
setWordCountInput('800');
} else {
setWordCountInput(String(num));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
articleType,
@@ -91,15 +126,15 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<Select
value={articleType}
onValueChange={(v) => setArticleType(v as AIWritingRequest['articleType'])}
disabled={disabled}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{ARTICLE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value} className="text-xs">
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>
))}
@@ -108,23 +143,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数</label>
<Select
value={String(wordCount)}
onValueChange={(v) => setWordCount(Number(v))}
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[500, 800, 1200, 2000, 3000].map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{n} 字
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
@@ -132,7 +162,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<input
type="text"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { Plus, RotateCcw, Square } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Plus, RotateCcw, Settings2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -35,6 +36,7 @@ const STATUS_LABEL: Record<string, string> = {
};
export function AIWritingPanel() {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
@@ -82,6 +84,15 @@ export function AIWritingPanel() {
{historySession.draft_title ?? historySession.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建对话
@@ -108,6 +119,15 @@ export function AIWritingPanel() {
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
{showNewButton && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import {
listArticleTypes,
createArticleType,
updateArticleType,
deleteArticleType,
} from '../api/ai-writing-article-types';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
export function useArticleTypes() {
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listArticleTypes();
setArticleTypes(data);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
const created = await createArticleType(data);
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
return created;
}, []);
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
const updated = await updateArticleType(id, data);
setArticleTypes((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteArticleType(id);
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
}, []);
return { articleTypes, loading, error, refresh, create, update, remove };
}
@@ -0,0 +1,431 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
// ── 样式常量 ──────────────────────────────────────────────────────────────────
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50 disabled:bg-muted';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring resize-none';
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
interface FormState {
key: string;
label: string;
description: string;
prompt_hint: string;
default_word_count: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
key: '',
label: '',
description: '',
prompt_hint: '',
default_word_count: '800',
sort_order: '0',
};
// ── 主页面 ────────────────────────────────────────────────────────────────────
export default function ArticleTypesConfigPage() {
const navigate = useNavigate();
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, [refresh]);
// ── 对话框操作 ──────────────────────────────────────────────────────────────
function openCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
}
function openEdit(t: ArticleType) {
setEditingId(t.id);
setForm({
key: t.key,
label: t.label,
description: t.description ?? '',
prompt_hint: t.prompt_hint ?? '',
default_word_count: String(t.default_word_count),
sort_order: String(t.sort_order),
});
setFormError(null);
setDialogOpen(true);
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function handleSave() {
const trimmedKey = form.key.trim();
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedKey) { setFormError('标识不能为空'); return; }
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
setSaving(true);
setFormError(null);
try {
if (editingId) {
const req: UpdateArticleTypeRequest = {
label: trimmedLabel,
description: form.description.trim() || null,
prompt_hint: form.prompt_hint.trim() || null,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await update(editingId, req);
} else {
const req: CreateArticleTypeRequest = {
key: trimmedKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await create(req);
}
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
// ── 删除操作 ────────────────────────────────────────────────────────────────
async function handleDelete() {
if (!deleteTargetId) return;
setDeleting(true);
setDeleteError(null);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
// ── 渲染 ────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
{/* 页面头部 */}
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate(-1)}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Settings2 className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold flex-1">文章类型配置</span>
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
<Plus className="size-3.5" />
新增类型
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
)}
{!loading && error && (
<p className="text-sm text-destructive text-center py-12">{error}</p>
)}
{!loading && !error && articleTypes.length === 0 && (
<div className="text-center py-12 space-y-2">
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无文章类型</p>
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
</div>
)}
{!loading && articleTypes.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{articleTypes.map((t) => (
<ArticleTypeCard
key={t.id}
type={t}
onEdit={() => openEdit(t)}
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
/>
))}
</div>
)}
</div>
{/* 新增 / 编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<FormField label="标识 (key)" required>
<input
className={INPUT_CLASS}
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
value={form.key}
onChange={(e) => setField('key', e.target.value)}
disabled={!!editingId}
/>
{editingId && (
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
)}
</FormField>
<FormField label="名称" required>
<input
className={INPUT_CLASS}
placeholder="如 新闻资讯、深度特稿"
value={form.label}
onChange={(e) => setField('label', e.target.value)}
/>
</FormField>
<FormField label="说明">
<input
className={INPUT_CLASS}
placeholder="简短描述,帮助用户了解该类型的适用场景"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<FormField label="写作风格提示词">
<textarea
className={TEXTAREA_CLASS}
rows={3}
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
value={form.prompt_hint}
onChange={(e) => setField('prompt_hint', e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="默认字数(字)" required>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
value={form.default_word_count}
onChange={(e) => setField('default_word_count', e.target.value)}
/>
</FormField>
<FormField label="排序权重">
<input
type="number"
min={0}
className={INPUT_CLASS}
placeholder="0"
value={form.sort_order}
onChange={(e) => setField('sort_order', e.target.value)}
/>
</FormField>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
取消
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteTargetId !== null}
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-2">
<p className="text-sm text-muted-foreground">
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
</p>
{deleteError && (
<p className="text-xs text-destructive">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTargetId(null)}
disabled={deleting}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? '删除中…' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
function ArticleTypeCard({
type,
onEdit,
onDelete,
}: {
type: ArticleType;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
{/* 标题行 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">{type.label}</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
{type.key}
</span>
</div>
{type.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
title="编辑"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive/70 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 写作提示词预览 */}
{type.prompt_hint && (
<div className="rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{type.prompt_hint}
</p>
</div>
)}
{/* 底部元信息 */}
<div className="flex items-center gap-3 pt-0.5 border-t">
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
<FileText className="h-3 w-3" />
默认 {type.default_word_count} 字
</span>
<span className="text-[11px] text-muted-foreground pt-2">
排序 {type.sort_order}
</span>
</div>
</div>
);
}
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
</div>
);
}
@@ -3,12 +3,14 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
@@ -124,7 +124,7 @@ export interface UserInterventionPayload {
export interface AIWritingRequest {
userIntent: string;
articleType: 'news' | 'analysis' | 'feature' | 'report';
articleType: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;
@@ -126,6 +126,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -231,6 +235,11 @@ def get_ai_writing_session_store(request: Request):
return getattr(request.app.state, "ai_writing_session_store", None)
def get_article_type_store(request: Request):
"""Return the article type store, or None when not configured."""
return getattr(request.app.state, "article_type_store", None)
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@@ -1,13 +1,17 @@
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
POST /api/ai-writing/start 启动写作流程,返回 session_id
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
GET /api/ai-writing/{id}/state 查询当前流程状态
GET /api/ai-writing/sessions 列出用户的会话历史
GET /api/ai-writing/sessions/{id} 获取单个历史会话
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
DELETE /api/ai-writing/sessions/{id} 删除会话
POST /api/ai-writing/start 启动写作流程,返回 session_id
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
GET /api/ai-writing/{id}/state 查询当前流程状态
GET /api/ai-writing/sessions 列出用户的会话历史
GET /api/ai-writing/sessions/{id} 获取单个历史会话
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
DELETE /api/ai-writing/sessions/{id} 删除会话
GET /api/ai-writing/article-types 列出所有文章类型
POST /api/ai-writing/article-types 新增文章类型
PATCH /api/ai-writing/article-types/{id} 更新文章类型
DELETE /api/ai-writing/article-types/{id} 删除文章类型
"""
from __future__ import annotations
@@ -59,6 +63,36 @@ class RenameSessionRequest(BaseModel):
title: str
class ArticleTypeRequest(BaseModel):
key: str
label: str
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: int = 800
sort_order: int = 0
class UpdateArticleTypeRequest(BaseModel):
key: Optional[str] = None
label: Optional[str] = None
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: Optional[int] = None
sort_order: Optional[int] = None
class ArticleTypeResponse(BaseModel):
id: str
key: str
label: str
description: Optional[str] = None
prompt_hint: Optional[str] = None
default_word_count: int
sort_order: int
created_at: str
updated_at: str
class SessionResponse(BaseModel):
id: str
user_id: Optional[str] = None
@@ -93,6 +127,9 @@ _session_interventions: dict[str, list] = {}
# session_id → True if session ended with error
_session_error_flags: dict[str, bool] = {}
# session_id → valid_article_types(list[dict] with key/label)
_session_article_types: dict[str, list[dict]] = {}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -100,7 +137,7 @@ def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _make_config(session_id: str, model_name: str = "") -> dict:
def _make_config(session_id: str, model_name: str = "", valid_article_types: list[dict] | None = None) -> dict:
return {
"configurable": {
"thread_id": session_id,
@@ -109,6 +146,8 @@ def _make_config(session_id: str, model_name: str = "") -> dict:
# 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk
"sse_queue": _event_queues.get(session_id),
"sse_session_id": session_id,
# 动态文章类型列表,intent_parser 从此处读取有效枚举
"valid_article_types": valid_article_types or _session_article_types.get(session_id),
}
}
@@ -124,7 +163,7 @@ def _truncate_title(text: str, max_len: int = 50) -> str:
async def start_writing(body: StartWritingRequest, request: Request):
"""启动写作流程,返回 session_id。"""
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
from app.gateway.deps import get_ai_writing_session_store, get_current_user
from app.gateway.deps import get_ai_writing_session_store, get_article_type_store, get_current_user
session_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
@@ -132,6 +171,18 @@ async def start_writing(body: StartWritingRequest, request: Request):
_session_model_names[session_id] = body.model_name
_session_interventions[session_id] = []
# 加载动态文章类型列表,注入给 intent_parser 节点
article_type_store = get_article_type_store(request)
valid_article_types: list[dict] | None = None
if article_type_store is not None:
try:
records = await article_type_store.list()
if records:
valid_article_types = [{"key": r["key"], "label": r["label"]} for r in records]
except Exception:
logger.exception("ai_writing: failed to load article types for session=%s", session_id)
_session_article_types[session_id] = valid_article_types or []
# 持久化会话记录(DB 可能未配置,则跳过)
repo = get_ai_writing_session_store(request)
user_id = await get_current_user(request)
@@ -165,7 +216,7 @@ async def start_writing(body: StartWritingRequest, request: Request):
"review_result": None,
"last_user_intervention": None,
}
config = _make_config(session_id, body.model_name)
config = _make_config(session_id, body.model_name, valid_article_types=valid_article_types)
asyncio.create_task(_stream_graph_segment(session_id, initial_state, config, AI_WRITING_GRAPH))
return StartWritingResponse(session_id=session_id)
@@ -228,6 +279,79 @@ async def delete_session(session_id: str, request: Request):
return {"success": True}
@router.get("/article-types", response_model=list[ArticleTypeResponse])
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
return []
return await store.list()
@router.post("/article-types", response_model=ArticleTypeResponse, status_code=201)
async def create_article_type(body: ArticleTypeRequest, request: Request):
"""新增文章类型。key 必须全局唯一。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
existing = await store.get_by_key(body.key)
if existing is not None:
raise HTTPException(status_code=409, detail=f"Article type key '{body.key}' already exists")
return await store.create(
key=body.key,
label=body.label,
description=body.description,
prompt_hint=body.prompt_hint,
default_word_count=body.default_word_count,
sort_order=body.sort_order,
)
@router.patch("/article-types/{article_type_id}", response_model=ArticleTypeResponse)
async def update_article_type(article_type_id: str, body: UpdateArticleTypeRequest, request: Request):
"""更新文章类型字段(只传需要修改的字段)。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
# 如果修改了 key,检查唯一性
if body.key is not None:
existing = await store.get_by_key(body.key)
if existing is not None and existing["id"] != article_type_id:
raise HTTPException(status_code=409, detail=f"Article type key '{body.key}' already exists")
_UNSET = object.__new__(object)
kwargs = {}
for field in ("key", "label", "description", "prompt_hint", "default_word_count", "sort_order"):
val = getattr(body, field)
if val is not None:
kwargs[field] = val
updated = await store.update(article_type_id, **kwargs)
if updated is None:
raise HTTPException(status_code=404, detail="Article type not found")
return updated
@router.delete("/article-types/{article_type_id}")
async def delete_article_type(article_type_id: str, request: Request):
"""删除文章类型。"""
from app.gateway.deps import get_article_type_store
store = get_article_type_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Article type store not available")
deleted = await store.delete(article_type_id)
if not deleted:
raise HTTPException(status_code=404, detail="Article type not found")
return {"success": True}
@router.get("/{session_id}/stream")
async def stream_progress(session_id: str):
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
@@ -427,6 +551,7 @@ async def _stream_graph_segment(
_session_user_ids.pop(session_id, None)
_session_interventions.pop(session_id, None)
_session_error_flags.pop(session_id, None)
_session_article_types.pop(session_id, None)
def _event_type(evt: dict) -> str:
@@ -6,36 +6,66 @@ from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState
from ._utils import llm_json
_SYSTEM = """你是一名写作助手。
# 兜底类型列表,当 config 中未注入 valid_article_types 时使用
_DEFAULT_ARTICLE_TYPES: list[dict] = [
{"key": "news", "label": "新闻资讯"},
{"key": "analysis", "label": "分析报道"},
{"key": "feature", "label": "深度特稿"},
{"key": "report", "label": "调研报告"},
]
_SYSTEM_TEMPLATE = """你是一名写作助手。
你的任务:分析用户的写作意图,提取结构化的写作配置。
【输出要求】
- 只输出一个 JSON 对象,不要任何解释文字,不要 markdown 代码块。
- 必须使用英文双引号 "。
- article_type 只能是 news / analysis / feature / report 之一。
- article_type 只能是以下之一:{type_options}。
【完整示例】
输入意图:帮我写一篇面向行业专家的深度文章,分析今年新能源汽车出口为什么大涨,大概两千字
正确输出:
{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因"}"""
{{"article_type": "feature", "target_audience": "行业专家", "word_count_target": 2000, "refined_intent": "分析今年新能源汽车出口高速增长的原因"}}"""
_USER = """用户写作意图:{user_intent}
_USER_TEMPLATE = """用户写作意图:{user_intent}
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
{{
"article_type": "news|analysis|feature|report 之一",
{{{{
"article_type": "{type_options} 之一",
"target_audience": "目标读者描述",
"word_count_target": 800,
"refined_intent": "精炼后的写作意图(一句话)"
}}"""
}}}}"""
def _build_type_options(types: list[dict]) -> str:
"""将类型列表格式化为提示词中的枚举说明,如 news(新闻资讯)/ analysis(分析报道)。"""
return " / ".join(f'{t["key"]}({t["label"]})' for t in types)
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
# 从 configurable 读取动态类型列表,未配置时退回默认值
configurable = (config or {}).get("configurable", {})
valid_article_types: list[dict] = configurable.get("valid_article_types") or _DEFAULT_ARTICLE_TYPES
valid_keys: list[str] = [t["key"] for t in valid_article_types]
default_key: str = valid_keys[0] if valid_keys else "analysis"
type_options = _build_type_options(valid_article_types)
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
type_options=" | ".join(valid_keys),
)
fallback_type = state.get("article_type", default_key)
if fallback_type not in valid_keys:
fallback_type = default_key
result = await llm_json(
_SYSTEM,
_USER.format(user_intent=state["user_intent"]),
system,
user,
fallback={
"article_type": state.get("article_type", "analysis"),
"article_type": fallback_type,
"target_audience": state.get("target_audience", "普通读者"),
"word_count_target": state.get("word_count_target", 800),
"refined_intent": state["user_intent"],
@@ -48,8 +78,10 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
"progress_events": [{"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}],
}
if result.get("article_type"):
updates["article_type"] = result["article_type"]
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
raw_type = result.get("article_type", "")
updates["article_type"] = raw_type if raw_type in valid_keys else fallback_type
if result.get("target_audience"):
updates["target_audience"] = result["target_audience"]
if result.get("word_count_target"):
@@ -0,0 +1,11 @@
from deerflow.persistence.article_types.model import ArticleTypeRow
from deerflow.persistence.article_types.sql import ArticleTypeRepository
def make_article_type_store(session_factory) -> ArticleTypeRepository | None:
if session_factory is None:
return None
return ArticleTypeRepository(session_factory)
__all__ = ["ArticleTypeRow", "ArticleTypeRepository", "make_article_type_store"]
@@ -0,0 +1,24 @@
"""ORM model for AI writing article types."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ArticleTypeRow(Base):
__tablename__ = "ai_writing_article_types"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
label: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
prompt_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
default_word_count: Mapped[int] = mapped_column(Integer, nullable=False, default=800)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
@@ -0,0 +1,123 @@
"""SQLAlchemy-backed article type storage."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.article_types.model import ArticleTypeRow
_UNSET = object()
class ArticleTypeRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ArticleTypeRow) -> dict:
d = row.to_dict()
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
return d
async def create(
self,
*,
key: str,
label: str,
description: str | None = None,
prompt_hint: str | None = None,
default_word_count: int = 800,
sort_order: int = 0,
) -> dict:
now = datetime.now(UTC)
row = ArticleTypeRow(
id=str(uuid.uuid4()),
key=key,
label=label,
description=description,
prompt_hint=prompt_hint,
default_word_count=default_word_count,
sort_order=sort_order,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def update(
self,
article_type_id: str,
*,
key=_UNSET,
label=_UNSET,
description=_UNSET,
prompt_hint=_UNSET,
default_word_count=_UNSET,
sort_order=_UNSET,
) -> dict | None:
async with self._sf() as session:
row = await session.get(ArticleTypeRow, article_type_id)
if row is None:
return None
if key is not _UNSET:
row.key = key
if label is not _UNSET:
row.label = label
if description is not _UNSET:
row.description = description
if prompt_hint is not _UNSET:
row.prompt_hint = prompt_hint
if default_word_count is not _UNSET:
row.default_word_count = default_word_count
if sort_order is not _UNSET:
row.sort_order = sort_order
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, article_type_id: str) -> dict | None:
async with self._sf() as session:
row = await session.get(ArticleTypeRow, article_type_id)
if row is None:
return None
return self._row_to_dict(row)
async def get_by_key(self, key: str) -> dict | None:
async with self._sf() as session:
stmt = select(ArticleTypeRow).where(ArticleTypeRow.key == key)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is None:
return None
return self._row_to_dict(row)
async def list(self) -> list[dict]:
stmt = select(ArticleTypeRow).order_by(ArticleTypeRow.sort_order.asc(), ArticleTypeRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def delete(self, article_type_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ArticleTypeRow, article_type_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def list_keys(self) -> list[str]:
stmt = select(ArticleTypeRow.key).order_by(ArticleTypeRow.sort_order.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return list(result.scalars())
@@ -0,0 +1,107 @@
"""Create ai_writing_article_types table with default types.
Revision ID: 20260517_02
Revises: 20260517_01
Create Date: 2026-05-17
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260517_02"
down_revision = "20260517_01"
branch_labels = None
depends_on = None
_DEFAULT_TYPES = [
{
"key": "news",
"label": "新闻资讯",
"description": "时效性强的新闻报道,注重事实准确、简洁客观",
"prompt_hint": "以新闻报道的风格写作,注重5W1H要素,语言简洁客观,突出时效性和事实准确性",
"default_word_count": 600,
"sort_order": 0,
},
{
"key": "analysis",
"label": "分析报道",
"description": "深入分析事件背景与影响,提供专业见解",
"prompt_hint": "以分析报道的风格写作,提供深度背景分析、多角度解读和专业见解,逻辑清晰",
"default_word_count": 1200,
"sort_order": 1,
},
{
"key": "feature",
"label": "深度特稿",
"description": "人物或事件的深度叙事,注重细节与故事性",
"prompt_hint": "以深度特稿的风格写作,注重叙事性和细节描写,人物刻画生动,故事性强,情感真实",
"default_word_count": 2000,
"sort_order": 2,
},
{
"key": "report",
"label": "调研报告",
"description": "数据驱动的专题研究报告,结构严谨",
"prompt_hint": "以调研报告的风格写作,数据详实,结构严谨(摘要/背景/发现/结论),语言专业规范",
"default_word_count": 3000,
"sort_order": 3,
},
]
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("ai_writing_article_types"):
op.create_table(
"ai_writing_article_types",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("key", sa.String(64), nullable=False, unique=True),
sa.Column("label", sa.String(128), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("prompt_hint", sa.Text, nullable=True),
sa.Column("default_word_count", sa.Integer, nullable=False, server_default="800"),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
# 预置默认文章类型(幂等,跳过已存在的 key)
conn = op.get_bind()
existing_keys = {row[0] for row in conn.execute(sa.text("SELECT key FROM ai_writing_article_types")).fetchall()}
now = datetime.now(UTC)
for t in _DEFAULT_TYPES:
if t["key"] not in existing_keys:
conn.execute(
sa.text(
"INSERT INTO ai_writing_article_types "
"(id, key, label, description, prompt_hint, default_word_count, sort_order, created_at, updated_at) "
"VALUES (:id, :key, :label, :description, :prompt_hint, :default_word_count, :sort_order, :created_at, :updated_at)"
),
{
"id": str(uuid.uuid4()),
"key": t["key"],
"label": t["label"],
"description": t["description"],
"prompt_hint": t["prompt_hint"],
"default_word_count": t["default_word_count"],
"sort_order": t["sort_order"],
"created_at": now,
"updated_at": now,
},
)
def downgrade() -> None:
if _has_table("ai_writing_article_types"):
op.drop_table("ai_writing_article_types")