文章类型添加
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 { apiFetch } from '@/core/api';
|
||||||
|
|
||||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||||
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
|
|
||||||
interface AIWritingFormProps {
|
interface AIWritingFormProps {
|
||||||
onSubmit: (req: AIWritingRequest) => void;
|
onSubmit: (req: AIWritingRequest) => void;
|
||||||
@@ -25,22 +26,46 @@ interface ModelOption {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARTICLE_TYPES: { value: AIWritingRequest['articleType']; label: string }[] = [
|
const INPUT_CLASS =
|
||||||
{ value: 'analysis', label: '分析报道' },
|
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||||||
{ value: 'news', label: '新闻资讯' },
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||||
{ value: 'feature', label: '深度特稿' },
|
'focus-visible:ring-ring disabled:opacity-50';
|
||||||
{ value: 'report', label: '调研报告' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||||
const [intent, setIntent] = useState('');
|
const [intent, setIntent] = useState('');
|
||||||
const [articleType, setArticleType] =
|
const [articleType, setArticleType] = useState('analysis');
|
||||||
useState<AIWritingRequest['articleType']>('analysis');
|
|
||||||
const [audience, setAudience] = useState('普通读者');
|
const [audience, setAudience] = useState('普通读者');
|
||||||
const [wordCount, setWordCount] = useState(800);
|
const [wordCountInput, setWordCountInput] = useState('800');
|
||||||
const [modelName, setModelName] = useState('');
|
const [modelName, setModelName] = useState('');
|
||||||
const [models, setModels] = useState<ModelOption[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
apiFetch(`${getBackendBaseURL()}/api/models`)
|
apiFetch(`${getBackendBaseURL()}/api/models`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -57,12 +82,22 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!intent.trim()) return;
|
if (!intent.trim()) return;
|
||||||
|
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||||
onSubmit({
|
onSubmit({
|
||||||
userIntent: intent.trim(),
|
userIntent: intent.trim(),
|
||||||
articleType,
|
articleType,
|
||||||
@@ -91,15 +126,15 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
|||||||
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
||||||
<Select
|
<Select
|
||||||
value={articleType}
|
value={articleType}
|
||||||
onValueChange={(v) => setArticleType(v as AIWritingRequest['articleType'])}
|
onValueChange={handleArticleTypeChange}
|
||||||
disabled={disabled}
|
disabled={disabled || typesLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ARTICLE_TYPES.map((t) => (
|
{articleTypes.map((t) => (
|
||||||
<SelectItem key={t.value} value={t.value} className="text-xs">
|
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||||||
{t.label}
|
{t.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -108,23 +143,18 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">目标字数</label>
|
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
|
||||||
<Select
|
<input
|
||||||
value={String(wordCount)}
|
type="number"
|
||||||
onValueChange={(v) => setWordCount(Number(v))}
|
min={100}
|
||||||
|
step={100}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
placeholder="800"
|
||||||
|
value={wordCountInput}
|
||||||
|
onChange={(e) => setWordCountInput(e.target.value)}
|
||||||
|
onBlur={handleWordCountBlur}
|
||||||
disabled={disabled}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,7 +162,7 @@ export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
|||||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
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="普通读者 / 行业专家 / 政策决策者…"
|
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||||
value={audience}
|
value={audience}
|
||||||
onChange={(e) => setAudience(e.target.value)}
|
onChange={(e) => setAudience(e.target.value)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -35,6 +36,7 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AIWritingPanel() {
|
export function AIWritingPanel() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setArtifact } = useCanvasGraphContext();
|
const { setArtifact } = useCanvasGraphContext();
|
||||||
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
|
const { state, startWriting, submitIntervention, stopWriting, reset } = useAIWriting();
|
||||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||||
@@ -82,6 +84,15 @@ export function AIWritingPanel() {
|
|||||||
{historySession.draft_title ?? historySession.title}
|
{historySession.draft_title ?? historySession.title}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<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}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||||
<Plus className="size-3" />
|
<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">
|
<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>
|
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<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 && (
|
{showNewButton && (
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||||
<Plus className="size-3" />
|
<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 CanvasHomePage from "../pages/CanvasHomePage";
|
||||||
import CanvasThreadPage from "../pages/CanvasThreadPage";
|
import CanvasThreadPage from "../pages/CanvasThreadPage";
|
||||||
import AIWritingPage from "../pages/AIWritingPage";
|
import AIWritingPage from "../pages/AIWritingPage";
|
||||||
|
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
||||||
|
|
||||||
export default function OpenCanvasRoutes() {
|
export default function OpenCanvasRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<CanvasHomePage />} />
|
<Route index element={<CanvasHomePage />} />
|
||||||
<Route path="ai-writing" element={<AIWritingPage />} />
|
<Route path="ai-writing" element={<AIWritingPage />} />
|
||||||
|
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export interface UserInterventionPayload {
|
|||||||
|
|
||||||
export interface AIWritingRequest {
|
export interface AIWritingRequest {
|
||||||
userIntent: string;
|
userIntent: string;
|
||||||
articleType: 'news' | 'analysis' | 'feature' | 'report';
|
articleType: string;
|
||||||
targetAudience: string;
|
targetAudience: string;
|
||||||
wordCountTarget: number;
|
wordCountTarget: number;
|
||||||
modelName?: string;
|
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)
|
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 event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
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)
|
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:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
|
"""AI 写作路由 —— 独立于原有路由,前缀 /api/ai-writing/
|
||||||
|
|
||||||
POST /api/ai-writing/start 启动写作流程,返回 session_id
|
POST /api/ai-writing/start 启动写作流程,返回 session_id
|
||||||
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
|
GET /api/ai-writing/{id}/stream SSE 流(进度/干预/草稿事件)
|
||||||
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
|
POST /api/ai-writing/{id}/resume 用户干预响应,恢复流程
|
||||||
GET /api/ai-writing/{id}/state 查询当前流程状态
|
GET /api/ai-writing/{id}/state 查询当前流程状态
|
||||||
GET /api/ai-writing/sessions 列出用户的会话历史
|
GET /api/ai-writing/sessions 列出用户的会话历史
|
||||||
GET /api/ai-writing/sessions/{id} 获取单个历史会话
|
GET /api/ai-writing/sessions/{id} 获取单个历史会话
|
||||||
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
|
PATCH /api/ai-writing/sessions/{id} 重命名会话标题
|
||||||
DELETE /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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -59,6 +63,36 @@ class RenameSessionRequest(BaseModel):
|
|||||||
title: str
|
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):
|
class SessionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: Optional[str] = None
|
user_id: Optional[str] = None
|
||||||
@@ -93,6 +127,9 @@ _session_interventions: dict[str, list] = {}
|
|||||||
# session_id → True if session ended with error
|
# session_id → True if session ended with error
|
||||||
_session_error_flags: dict[str, bool] = {}
|
_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"
|
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 {
|
return {
|
||||||
"configurable": {
|
"configurable": {
|
||||||
"thread_id": session_id,
|
"thread_id": session_id,
|
||||||
@@ -109,6 +146,8 @@ def _make_config(session_id: str, model_name: str = "") -> dict:
|
|||||||
# 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk
|
# 把 SSE 队列注入,供草稿/大纲节点直接推流式 chunk
|
||||||
"sse_queue": _event_queues.get(session_id),
|
"sse_queue": _event_queues.get(session_id),
|
||||||
"sse_session_id": 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):
|
async def start_writing(body: StartWritingRequest, request: Request):
|
||||||
"""启动写作流程,返回 session_id。"""
|
"""启动写作流程,返回 session_id。"""
|
||||||
from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH
|
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())
|
session_id = str(uuid.uuid4())
|
||||||
queue: asyncio.Queue = asyncio.Queue()
|
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_model_names[session_id] = body.model_name
|
||||||
_session_interventions[session_id] = []
|
_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 可能未配置,则跳过)
|
# 持久化会话记录(DB 可能未配置,则跳过)
|
||||||
repo = get_ai_writing_session_store(request)
|
repo = get_ai_writing_session_store(request)
|
||||||
user_id = await get_current_user(request)
|
user_id = await get_current_user(request)
|
||||||
@@ -165,7 +216,7 @@ async def start_writing(body: StartWritingRequest, request: Request):
|
|||||||
"review_result": None,
|
"review_result": None,
|
||||||
"last_user_intervention": 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))
|
asyncio.create_task(_stream_graph_segment(session_id, initial_state, config, AI_WRITING_GRAPH))
|
||||||
return StartWritingResponse(session_id=session_id)
|
return StartWritingResponse(session_id=session_id)
|
||||||
@@ -228,6 +279,79 @@ async def delete_session(session_id: str, request: Request):
|
|||||||
return {"success": True}
|
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")
|
@router.get("/{session_id}/stream")
|
||||||
async def stream_progress(session_id: str):
|
async def stream_progress(session_id: str):
|
||||||
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
|
"""SSE 流:推送进度事件、干预请求、草稿内容。"""
|
||||||
@@ -427,6 +551,7 @@ async def _stream_graph_segment(
|
|||||||
_session_user_ids.pop(session_id, None)
|
_session_user_ids.pop(session_id, None)
|
||||||
_session_interventions.pop(session_id, None)
|
_session_interventions.pop(session_id, None)
|
||||||
_session_error_flags.pop(session_id, None)
|
_session_error_flags.pop(session_id, None)
|
||||||
|
_session_article_types.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
def _event_type(evt: dict) -> str:
|
def _event_type(evt: dict) -> str:
|
||||||
|
|||||||
+44
-12
@@ -6,36 +6,66 @@ from langchain_core.runnables import RunnableConfig
|
|||||||
from ..state import AIWritingState
|
from ..state import AIWritingState
|
||||||
from ._utils import llm_json
|
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 代码块。
|
- 只输出一个 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(如果意图中未明确某字段,给出合理默认值):
|
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
|
||||||
{{
|
{{{{
|
||||||
"article_type": "news|analysis|feature|report 之一",
|
"article_type": "{type_options} 之一",
|
||||||
"target_audience": "目标读者描述",
|
"target_audience": "目标读者描述",
|
||||||
"word_count_target": 800,
|
"word_count_target": 800,
|
||||||
"refined_intent": "精炼后的写作意图(一句话)"
|
"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:
|
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(
|
result = await llm_json(
|
||||||
_SYSTEM,
|
system,
|
||||||
_USER.format(user_intent=state["user_intent"]),
|
user,
|
||||||
fallback={
|
fallback={
|
||||||
"article_type": state.get("article_type", "analysis"),
|
"article_type": fallback_type,
|
||||||
"target_audience": state.get("target_audience", "普通读者"),
|
"target_audience": state.get("target_audience", "普通读者"),
|
||||||
"word_count_target": state.get("word_count_target", 800),
|
"word_count_target": state.get("word_count_target", 800),
|
||||||
"refined_intent": state["user_intent"],
|
"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": "意图解析完成"}],
|
"progress_events": [{"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.get("article_type"):
|
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
|
||||||
updates["article_type"] = result["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"):
|
if result.get("target_audience"):
|
||||||
updates["target_audience"] = result["target_audience"]
|
updates["target_audience"] = result["target_audience"]
|
||||||
if result.get("word_count_target"):
|
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