fix: tự động chạy ai
This commit is contained in:
Generated
-12
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user