文章类型添加
This commit is contained in:
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:
|
||||
|
||||
+44
-12
@@ -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"):
|
||||
|
||||
+11
@@ -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"]
|
||||
+24
@@ -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))
|
||||
+123
@@ -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())
|
||||
+107
@@ -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")
|
||||
Reference in new issue
Block a user