fix: tự động chạy ai

This commit is contained in:
2026-07-10 07:24:34 +07:00
parent c3a69d145b
commit f4322ff815
16 changed files with 529 additions and 164 deletions
-12
View File
@@ -73,7 +73,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1796,7 +1795,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1856,7 +1854,6 @@
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0",
@@ -2382,7 +2379,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2726,7 +2722,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3304,7 +3299,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3490,7 +3484,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5715,7 +5708,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5725,7 +5717,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6427,7 +6418,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6590,7 +6580,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6866,7 +6855,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+319 -64
View File
@@ -1,50 +1,163 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { api } from "@/services/api";
const DRAFT_KEY = "tinix_create_draft";
const DEFAULT_FORM = {
title: "",
genre: "",
sub_genres: [] as string[],
style: "",
character_setting: "",
world_setting: "",
plot_idea: "",
custom_outline_prompt: "",
total_chapters: 20
};
function loadDraft(): { form: typeof DEFAULT_FORM; outline: string } {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
form: { ...DEFAULT_FORM, ...(parsed.form || {}) },
outline: parsed.outline || ""
};
}
} catch {
// ignore corrupted draft
}
return { form: { ...DEFAULT_FORM }, outline: "" };
}
export default function CreatePage() {
const [genres, setGenres] = useState<any[]>([]);
const initial = useRef(loadDraft()).current;
const [genres, setGenres] = useState<string[]>([]);
const [subGenres, setSubGenres] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: "",
genre: "",
character_setting: "",
world_setting: "",
plot_idea: "",
total_chapters: 20
});
const [outline, setOutline] = useState<string>("");
const [formData, setFormData] = useState(initial.form);
const [outline, setOutline] = useState<string>(initial.outline);
const [statusMsg, setStatusMsg] = useState<string>("");
// Per-field AI suggestion UI state
const [suggestPrompt, setSuggestPrompt] = useState({
title: "",
char: "",
world: "",
plot: ""
});
const [suggesting, setSuggesting] = useState<{ [k: string]: boolean }>({});
// Persist to localStorage on every change (fixes reload content loss)
useEffect(() => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formData, outline }));
}, [formData, outline]);
// Fetch genres / styles on mount (do NOT overwrite persisted selections)
useEffect(() => {
// Fetch genres on mount
api.getGenres().then((data) => {
setGenres(data || []);
if (data && data.length > 0) {
setFormData((prev) => ({ ...prev, genre: data[0].name }));
const names = (data || []).map((g: any) => g.name ?? g);
setGenres(names);
if (!formData.genre && names.length > 0) {
setFormData((prev) => ({ ...prev, genre: names[0] }));
}
}).catch(err => {
console.error("Error fetching genres:", err);
});
}).catch((err) => console.error("Error fetching genres:", err));
api.getStyles().then((data) => {
const names = (data || []).map((s: any) => s.name ?? s);
setStyles(names);
if (!formData.style && names.length > 0) {
setFormData((prev) => ({ ...prev, style: names[0] }));
}
}).catch((err) => console.error("Error fetching styles:", err));
}, []);
// Load sub-genres when genre changes
useEffect(() => {
if (!formData.genre) return;
api.getSubGenres(formData.genre).then((data) => {
const names = (data || []).map((s: any) => s.name ?? s);
setSubGenres(names);
setFormData((prev) => ({
...prev,
sub_genres: prev.sub_genres.filter((sg) => names.includes(sg))
}));
}).catch(() => setSubGenres([]));
}, [formData.genre]);
const toggleSubGenre = (sg: string) => {
setFormData((prev) => {
const has = prev.sub_genres.includes(sg);
return {
...prev,
sub_genres: has
? prev.sub_genres.filter((x) => x !== sg)
: [...prev.sub_genres, sg]
};
});
};
const handleSuggest = async (
type: 'title' | 'char' | 'world' | 'plot'
) => {
setSuggesting((s) => ({ ...s, [type]: true }));
setStatusMsg("");
try {
const res = await api.suggest({
type,
genre: formData.genre,
sub_genres: formData.sub_genres,
title: formData.title,
character_setting: formData.character_setting,
world_setting: formData.world_setting,
custom_prompt: suggestPrompt[type],
num_main_chars: 2,
num_sub_chars: 3
});
const content = res.content || "";
if (type === 'title') {
// suggestion returns a list of titles -> pick the first
const first = content.split("\n").map((s: string) => s.trim()).filter(Boolean)[0] || content;
setFormData((prev) => ({ ...prev, title: first }));
} else if (type === 'char') {
setFormData((prev) => ({ ...prev, character_setting: content }));
} else if (type === 'world') {
setFormData((prev) => ({ ...prev, world_setting: content }));
} else if (type === 'plot') {
setFormData((prev) => ({ ...prev, plot_idea: content }));
}
setStatusMsg(res.message || "Gợi ý đã được điền.");
} catch (err: any) {
setStatusMsg("Lỗi gợi ý: " + err.message);
} finally {
setSuggesting((s) => ({ ...s, [type]: false }));
}
};
const handleGenerateOutline = async () => {
try {
setLoading(true);
setStatusMsg("Generating outline. Please wait...");
setOutline("");
const res = await api.generateOutline({
title: formData.title,
genre: formData.genre,
sub_genres: [],
sub_genres: formData.sub_genres,
total_chapters: formData.total_chapters,
character_setting: formData.character_setting,
world_setting: formData.world_setting,
plot_idea: formData.plot_idea
plot_idea: formData.plot_idea,
custom_outline_prompt: formData.custom_outline_prompt
});
setOutline(res.content || "");
setStatusMsg(res.message || "Success");
} catch (err: any) {
@@ -54,70 +167,158 @@ export default function CreatePage() {
}
};
const handleClearDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setFormData({ ...DEFAULT_FORM, genre: formData.genre, style: formData.style });
setOutline("");
setSuggestPrompt({ title: "", char: "", world: "", plot: "" });
setStatusMsg("");
};
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Create Story</h1>
<p className="text-zinc-500 mt-2">Design your novel settings and generate an outline.</p>
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Create Story</h1>
<p className="text-zinc-500 mt-2">Design your novel settings and generate an outline.</p>
</div>
<button
onClick={handleClearDraft}
className="text-xs px-3 py-2 rounded-md border border-border-glass text-zinc-400 hover:text-white hover:border-brand-primary transition-colors"
>
Clear draft
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Form Column */}
<div className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Novel Title</label>
<input
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
placeholder="Enter your novel title..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Genre</label>
<select
<select
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.genre}
onChange={(e) => setFormData({...formData, genre: e.target.value})}
onChange={(e) => setFormData({ ...formData, genre: e.target.value })}
>
{genres.map(g => (
<option key={g.name} value={g.name}>{g.name}</option>
{genres.length === 0 && <option value="">-- no genres --</option>}
{genres.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</div>
{subGenres.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Sub-genres</label>
<div className="flex flex-wrap gap-2">
{subGenres.map((sg) => {
const active = formData.sub_genres.includes(sg);
return (
<button
key={sg}
onClick={() => toggleSubGenre(sg)}
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
active
? "bg-brand-primary text-black border-brand-primary"
: "border-border-glass text-zinc-400 hover:text-white"
}`}
>
{sg}
</button>
);
})}
</div>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Character Settings</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={formData.character_setting}
onChange={(e) => setFormData({...formData, character_setting: e.target.value})}
placeholder="Describe main characters, personalities..."
<label className="text-sm font-medium text-zinc-300">Writing Style</label>
<select
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.style}
onChange={(e) => setFormData({ ...formData, style: e.target.value })}
>
{styles.length === 0 && <option value="">-- no styles --</option>}
{styles.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<AiField
label="Novel Title"
placeholder="Enter your novel title..."
value={formData.title}
onChange={(v) => setFormData({ ...formData, title: v })}
promptValue={suggestPrompt.title}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, title: v }))}
onSuggest={() => handleSuggest("title")}
suggesting={suggesting.title}
promptPlaceholder="Optional prompt for AI title suggestion..."
/>
<AiField
label="Character Settings"
placeholder="Describe main characters, personalities..."
value={formData.character_setting}
onChange={(v) => setFormData({ ...formData, character_setting: v })}
promptValue={suggestPrompt.char}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, char: v }))}
onSuggest={() => handleSuggest("char")}
suggesting={suggesting.char}
promptPlaceholder="Optional prompt for AI character suggestion..."
textarea
/>
<AiField
label="World Setting"
placeholder="Describe the world, magic system, laws..."
value={formData.world_setting}
onChange={(v) => setFormData({ ...formData, world_setting: v })}
promptValue={suggestPrompt.world}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, world: v }))}
onSuggest={() => handleSuggest("world")}
suggesting={suggesting.world}
promptPlaceholder="Optional prompt for AI world suggestion..."
textarea
/>
<AiField
label="Plot Idea"
placeholder="Main conflict, goals, and ending idea..."
value={formData.plot_idea}
onChange={(v) => setFormData({ ...formData, plot_idea: v })}
promptValue={suggestPrompt.plot}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, plot: v }))}
onSuggest={() => handleSuggest("plot")}
suggesting={suggesting.plot}
promptPlaceholder="Optional prompt for AI plot suggestion..."
textarea
/>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Custom Outline Prompt (optional)</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[80px] focus:border-brand-primary outline-none text-white"
value={formData.custom_outline_prompt}
onChange={(e) => setFormData({ ...formData, custom_outline_prompt: e.target.value })}
placeholder="Extra instructions for the outline..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">World Setting</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={formData.world_setting}
onChange={(e) => setFormData({...formData, world_setting: e.target.value})}
placeholder="Describe the world, magic system, laws..."
<label className="text-sm font-medium text-zinc-300">Total Chapters</label>
<input
type="number"
min={1}
max={200}
className="w-32 bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.total_chapters}
onChange={(e) => setFormData({ ...formData, total_chapters: Number(e.target.value) })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Plot Idea</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={formData.plot_idea}
onChange={(e) => setFormData({...formData, plot_idea: e.target.value})}
placeholder="Main conflict, goals, and ending idea..."
/>
</div>
<button
<button
disabled={loading || !formData.title || !formData.genre}
onClick={handleGenerateOutline}
className="w-full py-3 bg-brand-primary text-black font-semibold rounded-md hover:bg-brand-secondary transition-colors disabled:opacity-50"
@@ -129,14 +330,14 @@ export default function CreatePage() {
{/* Outline / Results Column */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-foreground">Generated Outline</h2>
{statusMsg && (
<div className="p-3 bg-blue-900/30 border border-blue-500/30 text-blue-300 rounded-md text-sm">
{statusMsg}
</div>
)}
<textarea
<textarea
className="w-full bg-black/40 border border-border-glass rounded-md p-4 text-sm h-[600px] font-mono text-zinc-300 focus:border-brand-primary outline-none resize-none"
value={outline}
readOnly
@@ -147,3 +348,57 @@ export default function CreatePage() {
</div>
);
}
interface AiFieldProps {
label: string;
placeholder: string;
value: string;
onChange: (v: string) => void;
promptValue: string;
onPromptChange: (v: string) => void;
onSuggest: () => void;
suggesting: boolean;
promptPlaceholder: string;
textarea?: boolean;
}
function AiField({
label, placeholder, value, onChange,
promptValue, onPromptChange, onSuggest, suggesting, promptPlaceholder, textarea
}: AiFieldProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">{label}</label>
{textarea ? (
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
) : (
<input
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
)}
<div className="flex gap-2 items-center">
<input
className="flex-1 bg-background-card border border-border-glass rounded-md p-2 text-xs focus:border-brand-primary outline-none text-zinc-300"
value={promptValue}
onChange={(e) => onPromptChange(e.target.value)}
placeholder={promptPlaceholder}
/>
<button
onClick={onSuggest}
disabled={suggesting}
className="px-3 py-2 text-xs rounded-md bg-brand-primary text-black font-medium hover:bg-brand-secondary transition-colors disabled:opacity-50 whitespace-nowrap"
>
{suggesting ? "AI..." : "AI gợi ý"}
</button>
</div>
</div>
);
}
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
+36 -1
View File
@@ -20,6 +20,18 @@ export interface OutlineReq {
custom_outline_prompt?: string;
}
export interface SuggestionReq {
type: 'title' | 'char' | 'world' | 'plot';
genre: string;
sub_genres?: string[];
title?: string;
character_setting?: string;
world_setting?: string;
custom_prompt?: string;
num_main_chars?: number;
num_sub_chars?: number;
}
export const api = {
// Check health
async checkHealth() {
@@ -48,7 +60,30 @@ export const api = {
return res.json();
},
// Generate outline
// Get sub-genres for a genre
async getSubGenres(genre: string) {
const res = await fetch(`${API_BASE_URL}/sub-genres/by-genre/${encodeURIComponent(genre)}`);
if (!res.ok) throw new Error('Failed to fetch sub-genres');
return res.json();
},
// Get writing styles
async getStyles() {
const res = await fetch(`${API_BASE_URL}/styles`);
if (!res.ok) throw new Error('Failed to fetch styles');
return res.json();
},
// Get AI suggestion (title / char / world / plot)
async suggest(data: SuggestionReq) {
const res = await fetch(`${API_BASE_URL}/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Suggestion failed');
return res.json();
},
async generateOutline(data: OutlineReq) {
const res = await fetch(`${API_BASE_URL}/generate-outline`, {
method: 'POST',