Commit ·
34d6b36
1
Parent(s): 7a0df5b
Update article generation and use langfuse
Browse files- package-lock.json +26 -0
- package.json +1 -0
- src/app/api/generate-article/route.ts +14 -12
- src/app/page.tsx +2 -2
- src/components/{source-article/SourceArticle.tsx → article/Article.tsx} +6 -5
- src/components/{source-article → article}/ArticleGenerationModal.tsx +44 -115
- src/components/{source-article → article}/index.ts +1 -1
- src/components/ui/checkbox-field.tsx +35 -0
- src/components/ui/checkbox-grid.tsx +41 -0
- src/components/ui/checkbox-group.tsx +35 -0
- src/config/ai.ts +5 -0
- src/lib/langfuse.ts +19 -0
package-lock.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
"@ai-sdk/openai": "^2.0.0",
|
| 12 |
"@ai-sdk/react": "^2.0.1",
|
| 13 |
"@hookform/resolvers": "^5.2.1",
|
|
|
|
| 14 |
"@radix-ui/react-checkbox": "^1.3.3",
|
| 15 |
"@radix-ui/react-dialog": "^1.1.15",
|
| 16 |
"@radix-ui/react-label": "^2.1.7",
|
|
@@ -659,6 +660,23 @@
|
|
| 659 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 660 |
}
|
| 661 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
"node_modules/@napi-rs/wasm-runtime": {
|
| 663 |
"version": "0.2.12",
|
| 664 |
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
|
@@ -4570,6 +4588,14 @@
|
|
| 4570 |
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 4571 |
"dev": true
|
| 4572 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4573 |
"node_modules/nanoid": {
|
| 4574 |
"version": "3.3.11",
|
| 4575 |
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/nanoid/-/nanoid-3.3.11.tgz",
|
|
|
|
| 11 |
"@ai-sdk/openai": "^2.0.0",
|
| 12 |
"@ai-sdk/react": "^2.0.1",
|
| 13 |
"@hookform/resolvers": "^5.2.1",
|
| 14 |
+
"@langfuse/client": "^4.0.1",
|
| 15 |
"@radix-ui/react-checkbox": "^1.3.3",
|
| 16 |
"@radix-ui/react-dialog": "^1.1.15",
|
| 17 |
"@radix-ui/react-label": "^2.1.7",
|
|
|
|
| 660 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 661 |
}
|
| 662 |
},
|
| 663 |
+
"node_modules/@langfuse/client": {
|
| 664 |
+
"version": "4.0.1",
|
| 665 |
+
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/@langfuse/client/-/client-4.0.1.tgz",
|
| 666 |
+
"integrity": "sha512-aeY8VX8zR4DyNQ58sMyWW5xI3KqJ4zfi/TReJORTLkxx8WTSuEnyiFdj6gXDO9bzBXRexJkq2l9tCAldUyp4xQ==",
|
| 667 |
+
"dependencies": {
|
| 668 |
+
"@langfuse/core": "^4.0.1",
|
| 669 |
+
"mustache": "^4.2.0"
|
| 670 |
+
},
|
| 671 |
+
"peerDependencies": {
|
| 672 |
+
"@opentelemetry/api": "^1.9.0"
|
| 673 |
+
}
|
| 674 |
+
},
|
| 675 |
+
"node_modules/@langfuse/core": {
|
| 676 |
+
"version": "4.0.1",
|
| 677 |
+
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/@langfuse/core/-/core-4.0.1.tgz",
|
| 678 |
+
"integrity": "sha512-+KfFvFzt1yFrFH+U18Q5GMNihGbxt/ApJhHD0n/RsffTb23yFUZz1Ya7Sl/3YRZzSmWDuTWHePAHSTRrCylzTQ=="
|
| 679 |
+
},
|
| 680 |
"node_modules/@napi-rs/wasm-runtime": {
|
| 681 |
"version": "0.2.12",
|
| 682 |
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
|
|
|
| 4588 |
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 4589 |
"dev": true
|
| 4590 |
},
|
| 4591 |
+
"node_modules/mustache": {
|
| 4592 |
+
"version": "4.2.0",
|
| 4593 |
+
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/mustache/-/mustache-4.2.0.tgz",
|
| 4594 |
+
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
|
| 4595 |
+
"bin": {
|
| 4596 |
+
"mustache": "bin/mustache"
|
| 4597 |
+
}
|
| 4598 |
+
},
|
| 4599 |
"node_modules/nanoid": {
|
| 4600 |
"version": "3.3.11",
|
| 4601 |
"resolved": "http://artifactory-build.taboolasyndication.com:80/artifactory/api/npm/npm-public/nanoid/-/nanoid-3.3.11.tgz",
|
package.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
| 12 |
"@ai-sdk/openai": "^2.0.0",
|
| 13 |
"@ai-sdk/react": "^2.0.1",
|
| 14 |
"@hookform/resolvers": "^5.2.1",
|
|
|
|
| 15 |
"@radix-ui/react-checkbox": "^1.3.3",
|
| 16 |
"@radix-ui/react-dialog": "^1.1.15",
|
| 17 |
"@radix-ui/react-label": "^2.1.7",
|
|
|
|
| 12 |
"@ai-sdk/openai": "^2.0.0",
|
| 13 |
"@ai-sdk/react": "^2.0.1",
|
| 14 |
"@hookform/resolvers": "^5.2.1",
|
| 15 |
+
"@langfuse/client": "^4.0.1",
|
| 16 |
"@radix-ui/react-checkbox": "^1.3.3",
|
| 17 |
"@radix-ui/react-dialog": "^1.1.15",
|
| 18 |
"@radix-ui/react-label": "^2.1.7",
|
src/app/api/generate-article/route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { openai } from '@ai-sdk/openai';
|
| 2 |
import { generateText } from 'ai';
|
| 3 |
-
import {
|
|
|
|
| 4 |
|
| 5 |
export async function POST(req: Request) {
|
| 6 |
try {
|
|
@@ -14,19 +15,20 @@ export async function POST(req: Request) {
|
|
| 14 |
inputArticle
|
| 15 |
} = await req.json();
|
| 16 |
|
| 17 |
-
//
|
| 18 |
-
const
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
const result = await generateText({
|
| 29 |
-
model: openai(
|
| 30 |
prompt: prompt,
|
| 31 |
});
|
| 32 |
|
|
|
|
| 1 |
import { openai } from '@ai-sdk/openai';
|
| 2 |
import { generateText } from 'ai';
|
| 3 |
+
import { langfuse } from '@/lib/langfuse';
|
| 4 |
+
import { AI_CONFIG } from '@/config/ai';
|
| 5 |
|
| 6 |
export async function POST(req: Request) {
|
| 7 |
try {
|
|
|
|
| 15 |
inputArticle
|
| 16 |
} = await req.json();
|
| 17 |
|
| 18 |
+
// Get the prompt from Langfuse (FREE - no cost for prompt management)
|
| 19 |
+
const langfusePrompt = await langfuse.prompt.get('article/generation');
|
| 20 |
+
|
| 21 |
+
// Replace placeholders with actual values
|
| 22 |
+
const prompt = langfusePrompt.prompt
|
| 23 |
+
.replace(/\{\{grade_values\}\}/g, grade.join(', '))
|
| 24 |
+
.replace(/\{\{unit_values\}\}/g, unit.join(', '))
|
| 25 |
+
.replace(/\{\{textbook_vocab_values\}\}/g, textbookVocab)
|
| 26 |
+
.replace(/\{\{additional_vocab_values\}\}/g, additionalVocab)
|
| 27 |
+
.replace(/\{\{grammar_values\}\}/g, grammar.join(', '))
|
| 28 |
+
.replace(/\{\{topic_values\}\}/g, topic.join(', '));
|
| 29 |
|
| 30 |
const result = await generateText({
|
| 31 |
+
model: openai(AI_CONFIG.model),
|
| 32 |
prompt: prompt,
|
| 33 |
});
|
| 34 |
|
src/app/page.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|
| 4 |
import { useChat } from '@ai-sdk/react';
|
| 5 |
import { QuestionParameterForm } from '@/components/question-creation';
|
| 6 |
import { QuestionList } from '@/components/question-output';
|
| 7 |
-
import {
|
| 8 |
import { QuestionType, QuestionParameters, GeneratedQuestion } from '@/types/quiz';
|
| 9 |
import { Button } from '@/components/ui/button';
|
| 10 |
import { Input } from '@/components/ui/input';
|
|
@@ -257,7 +257,7 @@ export default function QuestionBuilder() {
|
|
| 257 |
{/* Right side: Two-panel stack */}
|
| 258 |
<div className="flex-1 flex flex-col">
|
| 259 |
{/* Upper Right Panel: Source Article Editor */}
|
| 260 |
-
<
|
| 261 |
className="h-1/2"
|
| 262 |
sourceArticle={sourceArticle}
|
| 263 |
onSourceArticleChange={setSourceArticle}
|
|
|
|
| 4 |
import { useChat } from '@ai-sdk/react';
|
| 5 |
import { QuestionParameterForm } from '@/components/question-creation';
|
| 6 |
import { QuestionList } from '@/components/question-output';
|
| 7 |
+
import { Article } from '@/components/article';
|
| 8 |
import { QuestionType, QuestionParameters, GeneratedQuestion } from '@/types/quiz';
|
| 9 |
import { Button } from '@/components/ui/button';
|
| 10 |
import { Input } from '@/components/ui/input';
|
|
|
|
| 257 |
{/* Right side: Two-panel stack */}
|
| 258 |
<div className="flex-1 flex flex-col">
|
| 259 |
{/* Upper Right Panel: Source Article Editor */}
|
| 260 |
+
<Article
|
| 261 |
className="h-1/2"
|
| 262 |
sourceArticle={sourceArticle}
|
| 263 |
onSourceArticleChange={setSourceArticle}
|
src/components/{source-article/SourceArticle.tsx → article/Article.tsx}
RENAMED
|
@@ -8,7 +8,7 @@ import { Label } from '@/components/ui/label';
|
|
| 8 |
import { Textarea } from '@/components/ui/textarea';
|
| 9 |
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 10 |
|
| 11 |
-
interface
|
| 12 |
className?: string;
|
| 13 |
sourceArticle: string;
|
| 14 |
onSourceArticleChange: (text: string) => void;
|
|
@@ -16,13 +16,14 @@ interface SourceArticleProps {
|
|
| 16 |
onSourceLockedChange: (locked: boolean) => void;
|
| 17 |
}
|
| 18 |
|
| 19 |
-
export function
|
| 20 |
className = '',
|
| 21 |
sourceArticle,
|
| 22 |
onSourceArticleChange,
|
| 23 |
isSourceLocked,
|
| 24 |
onSourceLockedChange,
|
| 25 |
-
}:
|
|
|
|
| 26 |
const [wordCount, setWordCount] = useState(0);
|
| 27 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 28 |
const [isGeneratingArticle, setIsGeneratingArticle] = useState(false);
|
|
@@ -51,7 +52,7 @@ export function SourceArticle({
|
|
| 51 |
headers: { 'Content-Type': 'application/json' },
|
| 52 |
body: JSON.stringify(params),
|
| 53 |
});
|
| 54 |
-
|
| 55 |
if (response.ok) {
|
| 56 |
const { article } = await response.json();
|
| 57 |
onSourceArticleChange(article);
|
|
@@ -95,7 +96,7 @@ export function SourceArticle({
|
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
</CardHeader>
|
| 98 |
-
|
| 99 |
<CardContent className="h-[calc(100%-80px)]">
|
| 100 |
<Textarea
|
| 101 |
value={sourceArticle}
|
|
|
|
| 8 |
import { Textarea } from '@/components/ui/textarea';
|
| 9 |
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 10 |
|
| 11 |
+
interface ArticleProps {
|
| 12 |
className?: string;
|
| 13 |
sourceArticle: string;
|
| 14 |
onSourceArticleChange: (text: string) => void;
|
|
|
|
| 16 |
onSourceLockedChange: (locked: boolean) => void;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
export function Article({
|
| 20 |
className = '',
|
| 21 |
sourceArticle,
|
| 22 |
onSourceArticleChange,
|
| 23 |
isSourceLocked,
|
| 24 |
onSourceLockedChange,
|
| 25 |
+
}: ArticleProps) {
|
| 26 |
+
|
| 27 |
const [wordCount, setWordCount] = useState(0);
|
| 28 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 29 |
const [isGeneratingArticle, setIsGeneratingArticle] = useState(false);
|
|
|
|
| 52 |
headers: { 'Content-Type': 'application/json' },
|
| 53 |
body: JSON.stringify(params),
|
| 54 |
});
|
| 55 |
+
|
| 56 |
if (response.ok) {
|
| 57 |
const { article } = await response.json();
|
| 58 |
onSourceArticleChange(article);
|
|
|
|
| 96 |
</div>
|
| 97 |
</div>
|
| 98 |
</CardHeader>
|
| 99 |
+
|
| 100 |
<CardContent className="h-[calc(100%-80px)]">
|
| 101 |
<Textarea
|
| 102 |
value={sourceArticle}
|
src/components/{source-article → article}/ArticleGenerationModal.tsx
RENAMED
|
@@ -10,10 +10,10 @@ import {
|
|
| 10 |
DialogFooter,
|
| 11 |
} from '@/components/ui/dialog';
|
| 12 |
import { Button } from '@/components/ui/button';
|
| 13 |
-
import { Checkbox } from '@/components/ui/checkbox';
|
| 14 |
import { Label } from '@/components/ui/label';
|
| 15 |
import { Textarea } from '@/components/ui/textarea';
|
| 16 |
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
|
| 17 |
import { Loader2 } from 'lucide-react';
|
| 18 |
|
| 19 |
interface ArticleGenerationModalProps {
|
|
@@ -129,78 +129,37 @@ export function ArticleGenerationModal({
|
|
| 129 |
Configure the parameters for article generation
|
| 130 |
</DialogDescription>
|
| 131 |
</DialogHeader>
|
| 132 |
-
|
| 133 |
<ScrollArea className="h-[calc(90vh-200px)] pr-4">
|
| 134 |
<form onSubmit={handleSubmit} className="space-y-6">
|
| 135 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
</Label>
|
| 153 |
-
</div>
|
| 154 |
-
))}
|
| 155 |
-
</div>
|
| 156 |
-
</div>
|
| 157 |
-
|
| 158 |
-
{/* Grade */}
|
| 159 |
-
<div>
|
| 160 |
-
<Label className="mb-2">學生年級 (Grade Level)</Label>
|
| 161 |
-
<div className="space-y-2">
|
| 162 |
-
{GRADE_OPTIONS.map(option => (
|
| 163 |
-
<div key={option} className="flex items-center space-x-2">
|
| 164 |
-
<Checkbox
|
| 165 |
-
id={`grade-${option}`}
|
| 166 |
-
checked={formData.grade.includes(option)}
|
| 167 |
-
onCheckedChange={() => handleCheckboxChange('grade', option)}
|
| 168 |
-
/>
|
| 169 |
-
<Label
|
| 170 |
-
htmlFor={`grade-${option}`}
|
| 171 |
-
className="text-sm font-normal cursor-pointer"
|
| 172 |
-
>
|
| 173 |
-
{option}
|
| 174 |
-
</Label>
|
| 175 |
-
</div>
|
| 176 |
-
))}
|
| 177 |
-
</div>
|
| 178 |
-
</div>
|
| 179 |
</div>
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
checked={formData.unit.includes(option)}
|
| 190 |
-
onCheckedChange={() => handleCheckboxChange('unit', option)}
|
| 191 |
-
/>
|
| 192 |
-
<Label
|
| 193 |
-
htmlFor={`unit-${option}`}
|
| 194 |
-
className="text-sm font-normal cursor-pointer"
|
| 195 |
-
>
|
| 196 |
-
{option}
|
| 197 |
-
</Label>
|
| 198 |
-
</div>
|
| 199 |
-
))}
|
| 200 |
-
</div>
|
| 201 |
-
</div>
|
| 202 |
|
| 203 |
-
{/* Textbook Vocabulary */}
|
| 204 |
<div>
|
| 205 |
<Label htmlFor="textbook-vocab" className="mb-2">
|
| 206 |
課本單字列表 (Textbook Vocabulary)
|
|
@@ -213,8 +172,6 @@ export function ArticleGenerationModal({
|
|
| 213 |
className="bg-muted"
|
| 214 |
/>
|
| 215 |
</div>
|
| 216 |
-
|
| 217 |
-
{/* Additional Vocabulary */}
|
| 218 |
<div>
|
| 219 |
<Label htmlFor="additional-vocab" className="mb-2">
|
| 220 |
額外單字列表 (Additional Vocabulary)
|
|
@@ -228,52 +185,24 @@ export function ArticleGenerationModal({
|
|
| 228 |
</div>
|
| 229 |
|
| 230 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
</Label>
|
| 248 |
-
</div>
|
| 249 |
-
))}
|
| 250 |
-
</div>
|
| 251 |
-
</div>
|
| 252 |
-
|
| 253 |
-
{/* Topic */}
|
| 254 |
-
<div>
|
| 255 |
-
<Label className="mb-2">主題範圍 (Topic)</Label>
|
| 256 |
-
<div className="space-y-2">
|
| 257 |
-
{TOPIC_OPTIONS.map(option => (
|
| 258 |
-
<div key={option} className="flex items-center space-x-2">
|
| 259 |
-
<Checkbox
|
| 260 |
-
id={`topic-${option}`}
|
| 261 |
-
checked={formData.topic.includes(option)}
|
| 262 |
-
onCheckedChange={() => handleCheckboxChange('topic', option)}
|
| 263 |
-
/>
|
| 264 |
-
<Label
|
| 265 |
-
htmlFor={`topic-${option}`}
|
| 266 |
-
className="text-sm font-normal cursor-pointer"
|
| 267 |
-
>
|
| 268 |
-
{option}
|
| 269 |
-
</Label>
|
| 270 |
-
</div>
|
| 271 |
-
))}
|
| 272 |
-
</div>
|
| 273 |
-
</div>
|
| 274 |
</div>
|
| 275 |
|
| 276 |
-
{/* Input Article */}
|
| 277 |
<div>
|
| 278 |
<Label htmlFor="input-article" className="mb-2">
|
| 279 |
初始文章 (Initial Article)
|
|
@@ -293,8 +222,8 @@ export function ArticleGenerationModal({
|
|
| 293 |
<Button type="button" variant="outline" onClick={onClose}>
|
| 294 |
Cancel
|
| 295 |
</Button>
|
| 296 |
-
<Button
|
| 297 |
-
type="submit"
|
| 298 |
onClick={handleSubmit}
|
| 299 |
disabled={isGenerating}
|
| 300 |
>
|
|
|
|
| 10 |
DialogFooter,
|
| 11 |
} from '@/components/ui/dialog';
|
| 12 |
import { Button } from '@/components/ui/button';
|
|
|
|
| 13 |
import { Label } from '@/components/ui/label';
|
| 14 |
import { Textarea } from '@/components/ui/textarea';
|
| 15 |
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 16 |
+
import { CheckboxField } from '@/components/ui/checkbox-field';
|
| 17 |
import { Loader2 } from 'lucide-react';
|
| 18 |
|
| 19 |
interface ArticleGenerationModalProps {
|
|
|
|
| 129 |
Configure the parameters for article generation
|
| 130 |
</DialogDescription>
|
| 131 |
</DialogHeader>
|
| 132 |
+
|
| 133 |
<ScrollArea className="h-[calc(90vh-200px)] pr-4">
|
| 134 |
<form onSubmit={handleSubmit} className="space-y-6">
|
| 135 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 136 |
+
<CheckboxField
|
| 137 |
+
label="出版社 (Publisher)"
|
| 138 |
+
fieldName="publisher"
|
| 139 |
+
value={formData.publisher}
|
| 140 |
+
onChange={(value: string) => handleCheckboxChange('publisher', value)}
|
| 141 |
+
options={PUBLISHER_OPTIONS}
|
| 142 |
+
columns={1}
|
| 143 |
+
/>
|
| 144 |
+
<CheckboxField
|
| 145 |
+
label="學生年級 (Grade Level)"
|
| 146 |
+
fieldName="grade"
|
| 147 |
+
value={formData.grade}
|
| 148 |
+
onChange={(value: string) => handleCheckboxChange('grade', value)}
|
| 149 |
+
options={GRADE_OPTIONS}
|
| 150 |
+
columns={1}
|
| 151 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</div>
|
| 153 |
|
| 154 |
+
<CheckboxField
|
| 155 |
+
label="課程範圍 (Unit Range)"
|
| 156 |
+
fieldName="unit"
|
| 157 |
+
value={formData.unit}
|
| 158 |
+
onChange={(value: string) => handleCheckboxChange('unit', value)}
|
| 159 |
+
options={UNIT_OPTIONS}
|
| 160 |
+
columns={3}
|
| 161 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
|
|
|
| 163 |
<div>
|
| 164 |
<Label htmlFor="textbook-vocab" className="mb-2">
|
| 165 |
課本單字列表 (Textbook Vocabulary)
|
|
|
|
| 172 |
className="bg-muted"
|
| 173 |
/>
|
| 174 |
</div>
|
|
|
|
|
|
|
| 175 |
<div>
|
| 176 |
<Label htmlFor="additional-vocab" className="mb-2">
|
| 177 |
額外單字列表 (Additional Vocabulary)
|
|
|
|
| 185 |
</div>
|
| 186 |
|
| 187 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 188 |
+
<CheckboxField
|
| 189 |
+
label="文法範圍 (Grammar Focus)"
|
| 190 |
+
fieldName="grammar"
|
| 191 |
+
value={formData.grammar}
|
| 192 |
+
onChange={(value: string) => handleCheckboxChange('grammar', value)}
|
| 193 |
+
options={GRAMMAR_OPTIONS}
|
| 194 |
+
columns={1}
|
| 195 |
+
/>
|
| 196 |
+
<CheckboxField
|
| 197 |
+
label="主題範圍 (Topic)"
|
| 198 |
+
fieldName="topic"
|
| 199 |
+
value={formData.topic}
|
| 200 |
+
onChange={(value: string) => handleCheckboxChange('topic', value)}
|
| 201 |
+
options={TOPIC_OPTIONS}
|
| 202 |
+
columns={1}
|
| 203 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
</div>
|
| 205 |
|
|
|
|
| 206 |
<div>
|
| 207 |
<Label htmlFor="input-article" className="mb-2">
|
| 208 |
初始文章 (Initial Article)
|
|
|
|
| 222 |
<Button type="button" variant="outline" onClick={onClose}>
|
| 223 |
Cancel
|
| 224 |
</Button>
|
| 225 |
+
<Button
|
| 226 |
+
type="submit"
|
| 227 |
onClick={handleSubmit}
|
| 228 |
disabled={isGenerating}
|
| 229 |
>
|
src/components/{source-article → article}/index.ts
RENAMED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
-
export {
|
| 2 |
export { ArticleGenerationModal } from './ArticleGenerationModal';
|
|
|
|
| 1 |
+
export { Article } from './Article';
|
| 2 |
export { ArticleGenerationModal } from './ArticleGenerationModal';
|
src/components/ui/checkbox-field.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Label } from '@/components/ui/label';
|
| 2 |
+
import { CheckboxGrid } from '@/components/ui/checkbox-grid';
|
| 3 |
+
|
| 4 |
+
interface CheckboxFieldProps {
|
| 5 |
+
label: string;
|
| 6 |
+
fieldName: string;
|
| 7 |
+
value: string[];
|
| 8 |
+
onChange: (value: string) => void;
|
| 9 |
+
options: string[];
|
| 10 |
+
columns?: 1 | 2 | 3 | 4;
|
| 11 |
+
className?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function CheckboxField({
|
| 15 |
+
label,
|
| 16 |
+
fieldName,
|
| 17 |
+
value,
|
| 18 |
+
onChange,
|
| 19 |
+
options,
|
| 20 |
+
columns = 1,
|
| 21 |
+
className = ''
|
| 22 |
+
}: CheckboxFieldProps) {
|
| 23 |
+
return (
|
| 24 |
+
<div className={className}>
|
| 25 |
+
<Label className="mb-2">{label}</Label>
|
| 26 |
+
<CheckboxGrid
|
| 27 |
+
options={options}
|
| 28 |
+
selectedValues={value}
|
| 29 |
+
onSelectionChange={onChange}
|
| 30 |
+
fieldName={fieldName}
|
| 31 |
+
columns={columns}
|
| 32 |
+
/>
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
}
|
src/components/ui/checkbox-grid.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Checkbox } from '@/components/ui/checkbox';
|
| 2 |
+
import { Label } from '@/components/ui/label';
|
| 3 |
+
|
| 4 |
+
interface CheckboxGridProps {
|
| 5 |
+
options: string[];
|
| 6 |
+
selectedValues: string[];
|
| 7 |
+
onSelectionChange: (value: string) => void;
|
| 8 |
+
fieldName: string;
|
| 9 |
+
columns?: 1 | 2 | 3 | 4;
|
| 10 |
+
className?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function CheckboxGrid({
|
| 14 |
+
options,
|
| 15 |
+
selectedValues,
|
| 16 |
+
onSelectionChange,
|
| 17 |
+
fieldName,
|
| 18 |
+
columns = 1,
|
| 19 |
+
className = ''
|
| 20 |
+
}: CheckboxGridProps) {
|
| 21 |
+
return (
|
| 22 |
+
<div className={`grid grid-cols-${columns} gap-2 ${className}`}>
|
| 23 |
+
{options.map(option => (
|
| 24 |
+
<div key={option} className="flex items-center space-x-2">
|
| 25 |
+
<Checkbox
|
| 26 |
+
id={`${fieldName}-${option}`}
|
| 27 |
+
checked={selectedValues.includes(option)}
|
| 28 |
+
onCheckedChange={() => onSelectionChange(option)}
|
| 29 |
+
/>
|
| 30 |
+
<Label
|
| 31 |
+
htmlFor={`${fieldName}-${option}`}
|
| 32 |
+
className="text-sm font-normal cursor-pointer"
|
| 33 |
+
>
|
| 34 |
+
{option}
|
| 35 |
+
</Label>
|
| 36 |
+
</div>
|
| 37 |
+
))}
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
src/components/ui/checkbox-group.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Label } from '@/components/ui/label';
|
| 2 |
+
import { CheckboxGrid } from '@/components/ui/checkbox-grid';
|
| 3 |
+
|
| 4 |
+
interface CheckboxFieldProps {
|
| 5 |
+
label: string;
|
| 6 |
+
fieldName: string;
|
| 7 |
+
value: string[];
|
| 8 |
+
onChange: (value: string) => void;
|
| 9 |
+
options: string[];
|
| 10 |
+
columns?: 1 | 2 | 3 | 4;
|
| 11 |
+
className?: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function CheckboxField({
|
| 15 |
+
label,
|
| 16 |
+
fieldName,
|
| 17 |
+
value,
|
| 18 |
+
onChange,
|
| 19 |
+
options,
|
| 20 |
+
columns = 1,
|
| 21 |
+
className = ''
|
| 22 |
+
}: CheckboxFieldProps) {
|
| 23 |
+
return (
|
| 24 |
+
<div className={className}>
|
| 25 |
+
<Label className="mb-2">{label}</Label>
|
| 26 |
+
<CheckboxGrid
|
| 27 |
+
options={options}
|
| 28 |
+
selectedValues={value}
|
| 29 |
+
onSelectionChange={onChange}
|
| 30 |
+
fieldName={fieldName}
|
| 31 |
+
columns={columns}
|
| 32 |
+
/>
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
}
|
src/config/ai.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const AI_CONFIG = {
|
| 2 |
+
model: 'gpt-4o-mini',
|
| 3 |
+
temperature: 0.7,
|
| 4 |
+
maxTokens: 1000,
|
| 5 |
+
} as const;
|
src/lib/langfuse.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { LangfuseClient } from "@langfuse/client";
|
| 2 |
+
|
| 3 |
+
// Ensure this is never imported on the client
|
| 4 |
+
if (typeof window !== "undefined") {
|
| 5 |
+
throw new Error("`langfuse` must only be used on the server side.");
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
declare global {
|
| 9 |
+
// eslint-disable-next-line no-var
|
| 10 |
+
var __langfuse__: LangfuseClient | undefined;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const langfuse = globalThis.__langfuse__ ?? new LangfuseClient({
|
| 14 |
+
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
| 15 |
+
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
| 16 |
+
baseUrl: process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com",
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
if (!globalThis.__langfuse__) globalThis.__langfuse__ = langfuse;
|