Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,31 @@ func (a *App) processTask(task *types.Task) {
}
}

// Generate long-form post article using the structured prompt
videoURL := task.URL
if videoURL == "" && task.VideoID != "" {
videoURL = "https://youtube.com/watch?v=" + task.VideoID
}
postBytes, postErr := a.summarizer.GeneratePostArticle(a.ctx, a.settings.APIKey, string(srtBytes), task.Title, task.Channel, videoURL, a.settings.Temperature, a.settings.MaxTokens)
postPath := fmt.Sprintf("%s/post_article.md", workDir)
if postErr != nil {
a.logger.Error("Post generation failed", "taskId", task.ID, "error", postErr)
_ = a.storage.SaveLog(workDir, "post", fmt.Sprintf("Post generation failed: %v", postErr))
placeholder := fmt.Sprintf("Article generation failed: %v\n", postErr)
if werr := os.WriteFile(postPath, []byte(placeholder), 0644); werr != nil {
a.logger.Error("Failed to write placeholder post", "taskId", task.ID, "error", werr)
}
} else {
content := strings.TrimSpace(string(postBytes)) + "\n"
if werr := os.WriteFile(postPath, []byte(content), 0644); werr != nil {
a.logger.Error("Failed to write post article", "taskId", task.ID, "error", werr)
_ = a.storage.SaveLog(workDir, "post", fmt.Sprintf("Failed to write post article: %v", werr))
} else {
_ = a.storage.SaveLog(workDir, "post", "Post article generated via OpenRouter")
a.logger.Info("Post article generated", "taskId", task.ID, "path", postPath)
}
}

// Mark as done
a.taskManager.UpdateTaskStatus(types.TaskStatusDone, 100)
a.emitReloadEvent()
Expand Down
56 changes: 53 additions & 3 deletions frontend/src/pages/TaskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default function TaskPage() {
const [subtitles, setSubtitles] = useState<any[]>([])
const [transcriptSubtitles, setTranscriptSubtitles] = useState<main.SubtitleEntry[]>([])
const [summary, setSummary] = useState<StructuredSummary | null>(null)
const [postContent, setPostContent] = useState<string | null>(null)
const [postStatus, setPostStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [postError, setPostError] = useState<string | null>(null)
const videoPlayerRef = useRef<VideoPlayerHandle | null>(null)

useEffect(() => {
Expand All @@ -44,13 +47,16 @@ export default function TaskPage() {

const loadTask = async () => {
if (!taskId) return

try {
setPostStatus('idle')
setPostContent(null)
setPostError(null)
const tasks = await GetAllTasks()
const task = tasks.find(t => t.id === taskId)
if (task) {
setVideo(task)

// Load video file if task is completed
if (task.status === 'done') {
try {
Expand Down Expand Up @@ -111,7 +117,7 @@ export default function TaskPage() {
src: `/media/${task.id}/${sub.file}`,
label: sub.label,
language: sub.lang,
default: index === 0 // 第一个字幕轨道设为默认
default: index === 0 // Set the first subtitle track as default
}))
setSubtitles(loadedSubs)
} catch (err) {
Expand All @@ -136,6 +142,29 @@ export default function TaskPage() {
} catch (err) {
console.error('Failed to load summary:', err)
}

// Load generated post article if available
try {
setPostStatus('loading')
const res = await fetch(`/media/${task.id}/post_article.md`)
if (res.ok) {
const text = await res.text()
setPostContent(text)
setPostStatus('success')
setPostError(null)
} else if (res.status === 404) {
setPostStatus('success')
setPostContent(null)
setPostError(null)
} else {
setPostStatus('error')
setPostError(`Unable to load article: ${res.status} ${res.statusText}`)
}
} catch (err) {
console.error('Failed to load post article:', err)
setPostStatus('error')
setPostError('Failed to load article content')
}
}
}
} catch (err) {
Expand Down Expand Up @@ -284,6 +313,7 @@ export default function TaskPage() {
<TabsList>
<TabsTrigger value="about">About</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="post">Post</TabsTrigger>
<TabsTrigger value="transcript">Transcript</TabsTrigger>
</TabsList>

Expand Down Expand Up @@ -356,6 +386,26 @@ export default function TaskPage() {
</div>
</TabsContent>

<TabsContent value="post" className="space-y-4">
{video.status === 'done' ? (
postStatus === 'loading' ? (
<div className="text-sm text-muted-foreground">Loading article...</div>
) : postStatus === 'error' ? (
<div className="space-y-2 text-sm text-destructive">
<p>{postError || 'Article not available.'}</p>
</div>
) : postContent ? (
<div className="rounded-xl bg-muted/40 p-5 text-base leading-relaxed whitespace-pre-wrap">
{postContent}
</div>
) : (
<div className="text-sm text-muted-foreground">Article is not available yet.</div>
)
) : (
<div className="text-sm text-muted-foreground">Article will be available after processing completes.</div>
)}
</TabsContent>

<TabsContent value="transcript" className="space-y-4">
{transcriptSubtitles.length > 0 ? (
<BilingualSubtitle
Expand Down
120 changes: 120 additions & 0 deletions internal/services/summarizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"os"
"strings"
"time"
)

Expand Down Expand Up @@ -181,3 +182,122 @@ func (c *OpenRouterClient) SummarizeStructured(ctx context.Context, apiKey strin
// The model is instructed to return a valid JSON object that matches the schema
return []byte(parsed.Choices[0].Message.Content), nil
}

// GeneratePostArticle creates a long-form Chinese article using the supplied creative brief and transcript
func (c *OpenRouterClient) GeneratePostArticle(ctx context.Context, apiKey string, transcript string, videoTitle string, creatorName string, videoURL string, temperature float64, maxTokens int) ([]byte, error) {
if apiKey == "" {
apiKey = os.Getenv("OPENROUTER_API_KEY")
}
if apiKey == "" {
return nil, fmt.Errorf("missing OpenRouter API key")
}

if temperature <= 0 {
temperature = 0.7
}
if maxTokens <= 0 {
maxTokens = 6144
}

title := strings.TrimSpace(videoTitle)
if title == "" {
title = "Untitled Video"
}
creator := strings.TrimSpace(creatorName)
if creator == "" {
creator = "Unknown Creator"
}
link := strings.TrimSpace(videoURL)
if link == "" {
link = "https://youtube.com"
}

normalizedTranscript := strings.ReplaceAll(transcript, "\r\n", "\n")

systemPrompt := `You are a top-tier long-form content creator and thought interpreter. Your craft turns any complex source into an architecturally sound, elegantly written, intellectually provocative Chinese essay. You do not list information—you illuminate ideas. Your prose must invite contemplation beyond simple comprehension.

Fully internalise every detail I provide, then craft an entirely original article in your own narrative voice. The output must be written in fluent Chinese, yet the creative brief you follow is written here in English.

Core creative principles:
1. Rebuild the ideas, never transcribe the wording. Absorb the source, rediscover its essence, and present it with fresh, insightful structure.
2. Treat titles as the soul of the essay. Craft an arresting master headline (optionally with a subtitle) and unique, compelling titles for every logical section. Avoid template labels such as “引言”, “正文”, or “总结”.
3. Let narrative drive everything. Even when explaining frameworks or sequences, rely on flowing paragraphs, graceful transitions, and cause-and-effect reasoning instead of bullet lists.

Production flow and delivery requirements:
Step 1 — Foundation and master title
- After understanding the full transcript, conceive a headline that captures the core thesis instantly.
- Include the following metadata at either the beginning or the end of the article using the exact labels provided later in this brief.

Step 2 — Opening movement
- Title: ignite curiosity or highlight the core tension.
- Content: open with a vivid scene, paradox, or problem that leads naturally into the big question the article tackles. Signal the unique value of reading on.

Step 3 — Core exploration (2–4 sections)
- Title: for each section, supply a concise, insightful micro-headline.
- Content: expand each theme with rich analysis, analogies, and probing questions. Integrate any step-by-step logic into narrative paragraphs that explain both the “what” and the “why”. Ensure seamless transitions between sections.

Step 4 — Elevation
- Title: name the distilled framework, mental model, or foundational logic you derive.
- Content: abstract the most universal insight from the story. Explain its components, mechanics, and philosophy, then describe how readers can apply it.

Step 5 — Resonant finale
- Title: deliver a philosophically charged or forward-looking closing.
- Content: rekindle the core thesis with a concise revelation, extend the insight to a broader arena, or leave the reader with a worthy open question.

Stylistic constraints:
- Write entirely in Chinese prose. Paragraphs only; avoid bullet points unless absolutely unavoidable for clarity.
- Speak with confident authority as an independent thinker. Do not reference any video, transcript, or instructions.
- Preserve proper nouns; on first mention provide the Chinese translation in parentheses if applicable.
- Deliver nothing but the finished article.
- Reproduce the metadata block using the exact label wording shared below.`

metaDirective := fmt.Sprintf("Source of Inspiration: %s\nOriginal Video: %s", creator, link)
userPrompt := fmt.Sprintf("Video Title: %s\nCreator Name: %s\nOriginal Video Link: %s\n\nInternalise all of the above, then write a long-form Chinese article that satisfies every element of the creative brief supplied in the system message. At the end of the article, append the metadata block exactly as shown here:\n%s\n\nFull transcript follows:\n%s", title, creator, link, metaDirective, normalizedTranscript)

reqBody := chatReq{
Model: "google/gemini-2.5-flash",
Messages: []chatMessage{{Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}},
MaxTokens: maxTokens,
Temperature: temperature,
}

data, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://openrouter.ai/api/v1/chat/completions", bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("HTTP-Referer", "https://github.com/strrl/transcube-webapp")
req.Header.Set("X-Title", "TransCube")

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("openrouter error: %s: %s", resp.Status, string(b))
}

var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &parsed); err != nil {
return nil, fmt.Errorf("failed to parse OpenRouter response: %v", err)
}
if len(parsed.Choices) == 0 || strings.TrimSpace(parsed.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("empty post response")
}

return []byte(parsed.Choices[0].Message.Content), nil
}