Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5ad81ec
docs(spec): 单文章 AI 汇总看板设计
Innei Aug 10, 2026
71fc549
feat(ai): add force regeneration semantics to summary/insights/transl…
Innei Aug 10, 2026
f25c6d1
fix(ai): propagate force into the PartialFailed translation retry pay…
Innei Aug 10, 2026
a6f7f7f
feat(admin): support multi-language input and force regen in AI gener…
Innei Aug 10, 2026
0abef0e
fix(admin): only gate too-many-langs on the langs prompt branch
Innei Aug 10, 2026
76688c3
chore: revert accidental page.tsx rename picked up by shared-worktree…
Innei Aug 10, 2026
c7b038c
feat(admin): wire multi-lang + force into AI generate call sites
Innei Aug 10, 2026
e960036
fix(admin): add missing useAiGenerateTask hook referenced by c7b038c68
Innei Aug 10, 2026
c32332b
feat(admin): per-article AI overview board
Innei Aug 10, 2026
28b7f71
fix(ai): close inflight bypass race window and cap targetLanguages at 8
Innei Aug 10, 2026
55e4e1f
refactor(admin): rename runWithLangPrompt to runWithGeneratePrompt
Innei Aug 10, 2026
8433347
fix(ai): fold force into the summary/insights/translation in-flight key
Innei Aug 10, 2026
4495acf
docs: add admin AI generate modal multilang design spec
Innei Aug 10, 2026
3822b09
feat(admin): active task list, language add control and overview sect…
Innei Aug 10, 2026
14c6d0e
fix(admin): keep the AI overview polling until a generation actually …
Innei Aug 10, 2026
1d53ffc
fix(ai): restore single in-flight lock and normalize target language …
Innei Aug 10, 2026
f472f46
fix(admin): fold region-suffixed language codes in AI generate modal …
Innei Aug 10, 2026
c380adf
fix(ai): re-check lock holder each poll while a force request waits
Innei Aug 10, 2026
f4720bd
fix(ai): clear streamKey/errorKey when a force leader acquires the lock
Innei Aug 10, 2026
74c9303
fix(ai): normalize target langs without clobbering unrecognized tokens
Innei Aug 10, 2026
357e312
fix(ai): reject blank target-language tokens instead of coining ''
Innei Aug 10, 2026
60b9734
fix(ai): normalize target langs before hashing the summary/translatio…
Innei Aug 10, 2026
5a09a03
fix(ai): give each in-flight leader run its own stream key
Innei Aug 10, 2026
dfb99d1
fix(ai): canonicalize languages and carry force/targets through the o…
Innei Aug 10, 2026
c85f823
feat(ai): unify summary and insights behind a shared multilang base-t…
Innei Aug 10, 2026
85ce422
refactor(admin): dispatch insights like summary — base first, transla…
Innei Aug 10, 2026
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
126 changes: 126 additions & 0 deletions apps/admin/src/api/ai-overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { ArticleInfo, GenerationMetrics, PaginationInfo } from './ai'
import { getJson } from './http'

export const AI_OVERVIEW_CAPABILITIES = [
'summary',
'insights',
'translation',
'tts',
] as const

export type AiOverviewCapability = (typeof AI_OVERVIEW_CAPABILITIES)[number]

export interface CapabilityCoverage {
applicable: boolean
expected: string[]
langs: string[]
}

export type ArticleCoverage = Record<
AiOverviewCapability,
CapabilityCoverage
> & {
sourceLang: string | null
}

export interface AiOverviewListRow {
article: ArticleInfo
coverage: ArticleCoverage
gapCount: number
}

export interface AiOverviewListResponse {
data: AiOverviewListRow[]
pagination: PaginationInfo
}

interface AssetBase {
createdAt: string
generationMetrics?: GenerationMetrics | null
id: string
lang: string
}

export interface SummaryAsset extends AssetBase {
isTranslation: boolean
sourceLang: string | null
summary: string
}

export interface InsightsAsset extends AssetBase {
content: string
isTranslation: boolean
sourceLang: string | null
}

export interface TranslationAsset extends AssetBase {
aiModel: string | null
sourceLang: string
updatedAt: string | null
}

export interface TtsAsset extends AssetBase {
charCount: number
durationMs: number | null
isTranslation: boolean
updatedAt: string | null
}

export interface CostBucket {
cacheReadTokens: number
cacheWriteTokens: number
costTotalUsd: number
generationCount: number
inputTokens: number
outputTokens: number
totalTokens: number
}

export const ACTIVE_TASK_STATUSES = ['pending', 'running'] as const

export interface ActiveGeneration {
capability: AiOverviewCapability
completedItems: number | null
error: string | null
langs: string[]
progress: number | null
progressMessage: string | null
startedAt: number | null
status: string
taskId: string
totalItems: number | null
}

export interface AiOverviewDetail {
activeTasks: ActiveGeneration[]
article: ArticleInfo
assets: {
insights: InsightsAsset[]
summary: SummaryAsset[]
translation: TranslationAsset[]
tts: TtsAsset[]
}
cost: {
byResourceType: Record<AiOverviewCapability, CostBucket>
models: string[]
total: CostBucket
}
coverage: ArticleCoverage
}

export const OVERVIEW_ARTICLE_TYPES = ['post', 'note', 'page'] as const

export type OverviewArticleType = (typeof OVERVIEW_ARTICLE_TYPES)[number]

export function getOverviewGrouped(params?: {
page?: number
search?: string
size?: number
type?: OverviewArticleType
}) {
return getJson<AiOverviewListResponse>('/ai/overview/grouped', params)
}

export function getArticleOverview(refId: string) {
return getJson<AiOverviewDetail>(`/ai/overview/article/${refId}`)
}
38 changes: 28 additions & 10 deletions apps/admin/src/api/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,24 @@ export function updateSummary(id: string, data: { summary: string }) {
return patchJson<AISummary, { summary: string }>(`/ai/summaries/${id}`, data)
}

export function createSummaryTask(data: { lang?: string; refId: string }) {
return postJson<CreateTaskResponse, { lang?: string; refId: string }>(
'/ai/summaries/task',
export function createSummaryTask(data: {
force?: boolean
refId: string
targetLanguages?: string[]
}) {
return postJson<
CreateTaskResponse,
{ force?: boolean; refId: string; targetLanguages?: string[] }
>('/ai/summaries/task', data)
}

export function createSummaryTranslationTask(data: {
force?: boolean
refId: string
targetLang: string
}) {
return postJson<CreateTaskResponse, typeof data>(
'/ai/summaries/task/translate',
data,
)
}
Expand All @@ -307,18 +322,20 @@ export function updateInsights(id: string, data: { content: string }) {
return patchJson<AIInsights, { content: string }>(`/ai/insights/${id}`, data)
}

export function createInsightsTask(data: { refId: string }) {
return postJson<CreateTaskResponse, { refId: string }>(
'/ai/insights/task',
data,
)
export function createInsightsTask(data: {
force?: boolean
refId: string
targetLanguages?: string[]
}) {
return postJson<CreateTaskResponse, typeof data>('/ai/insights/task', data)
}

export function createInsightsTranslationTask(data: {
force?: boolean
refId: string
targetLang: string
}) {
return postJson<CreateTaskResponse, { refId: string; targetLang: string }>(
return postJson<CreateTaskResponse, typeof data>(
'/ai/insights/task/translate',
data,
)
Expand Down Expand Up @@ -378,12 +395,13 @@ export function updateTranslation(
}

export function createTranslationTask(data: {
force?: boolean
refId: string
targetLanguages?: string[]
}) {
return postJson<
CreateTaskResponse,
{ refId: string; targetLanguages?: string[] }
{ force?: boolean; refId: string; targetLanguages?: string[] }
>('/ai/translations/task', data)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { adminQueryKeys } from '~/query/keys'
import { confirmDialog } from '~/ui/feedback/confirm'
import { ContentLayout, ContentLayoutSlot } from '~/ui/layout/content-layout'

import { useAiDefaultLangs } from '../../hooks/use-ai-default-langs'
import { getErrorMessage } from '../../utils/ai'
import { useArticleGroupedRouteContext } from './article-grouped-route-context'
import { ArticleDetailEmptyState } from './ArticleDetailEmptyState'
Expand All @@ -26,6 +27,7 @@ export function ArticleGroupedDetailRoute<TItem>() {
const queryClient = useQueryClient()
const ctx = useArticleGroupedRouteContext<TItem>()
const { config } = ctx
const defaultLangs = useAiDefaultLangs(config.generate.defaultLangsOptionKey)

const [editingItemId, setEditingItemId] = useState<string | null>(null)

Expand Down Expand Up @@ -94,7 +96,7 @@ export function ArticleGroupedDetailRoute<TItem>() {
})

const generateMutation = useMutation({
mutationFn: (input: { refId: string; lang?: string }) =>
mutationFn: (input: { refId: string; langs?: string[]; force?: boolean }) =>
config.generate.runTask(input),
onError: (error: unknown) =>
toast.error(getErrorMessage(error, t('ai.toast.taskCreateFailed'))),
Expand Down Expand Up @@ -130,13 +132,18 @@ export function ArticleGroupedDetailRoute<TItem>() {
const handleGenerate = async () => {
if (!id) return
const result = await presentGeneratePrompt({
defaultLangs,
inlineEmpty: t(config.inlineEmptyKey, { kind: t(config.kindKey) }),
langLabel: t('ai.translation.langLabel'),
langLabel: t('ai.generate.langsLabel'),
promptForLang: Boolean(config.generate.promptForLang),
title: t(config.generate.labelKey),
})
if (!result) return
await generateMutation.mutateAsync({ refId: id, lang: result.lang })
await generateMutation.mutateAsync({
force: result.force,
langs: result.langs,
refId: id,
})
}

const { keyboardActions, buildMenu } = useItemActions<TItem>({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,62 +1,102 @@
import type { FormEvent } from 'react'
import { useState } from 'react'

import { SmallBadge } from '~/features/tasks/components/TaskPrimitives'
import { useI18n } from '~/i18n'
import { ModalFooter, ModalHeader } from '~/ui/feedback/modal'
import { present, useModal } from '~/ui/feedback/modal-imperative'
import { Button } from '~/ui/primitives/button'
import { Checkbox } from '~/ui/primitives/checkbox'
import { TextInput } from '~/ui/primitives/text-field'

import { parseLangInput } from '../../utils/ai'

const MAX_LANGS = 8

export interface GeneratePromptModalProps {
title: string
promptForLang: boolean
langLabel: string
inlineEmpty?: string
defaultLangs?: string[]
}

export interface GeneratePromptResult {
lang?: string
langs: string[]
force: boolean
}

function GeneratePromptModal(props: GeneratePromptModalProps) {
const { t } = useI18n()
const modal = useModal<GeneratePromptResult>()
const [lang, setLang] = useState('zh')
const [langInput, setLangInput] = useState(
props.defaultLangs?.join(', ') ?? '',
)
const [force, setForce] = useState(false)

const langs = parseLangInput(langInput)
const tooMany = props.promptForLang && langs.length > MAX_LANGS

const handleSubmit = (event?: FormEvent) => {
event?.preventDefault()
if (props.promptForLang) {
const trimmed = lang.trim().toLowerCase()
if (!trimmed) return
modal.close({ lang: trimmed })
} else {
modal.close({})
}
if (tooMany) return
modal.close({
force,
langs: props.promptForLang ? langs : [],
})
}

return (
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
<ModalHeader title={props.title} />
<div className="space-y-4 px-5 py-4">
{props.promptForLang ? (
<TextInput
autoFocus
label={props.langLabel}
onChange={setLang}
placeholder="zh"
value={lang}
/>
<div className="grid gap-1.5 text-sm">
<TextInput
autoFocus
label={props.langLabel}
onChange={setLangInput}
placeholder="zh, en, ja"
value={langInput}
/>
<p className="text-xs text-fg-muted">
{t('ai.generate.langsHint')}
</p>
{langs.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{langs.map((lang) => (
<SmallBadge key={lang}>{lang}</SmallBadge>
))}
<span className="text-xs text-fg-muted">
{t('ai.generate.langsCount', { count: langs.length })}
</span>
</div>
) : null}
{tooMany ? (
<span className="text-xs text-red-500">
{t('ai.generate.langsTooMany', { max: MAX_LANGS })}
</span>
) : null}
</div>
) : (
<p className="text-sm text-fg-muted">
{props.inlineEmpty ?? props.title}
</p>
)}
<div className="grid gap-1">
<Checkbox
checked={force}
label={t('ai.generate.forceLabel')}
onCheckedChange={setForce}
/>
<p className="text-xs text-fg-muted">{t('ai.generate.forceHint')}</p>
</div>
</div>
<ModalFooter>
<Button onClick={() => modal.dismiss()} type="button" variant="subtle">
{t('common.cancel')}
</Button>
<Button type="submit" variant="primary">
<Button disabled={tooMany} type="submit" variant="primary">
{props.title}
</Button>
</ModalFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,12 @@ export interface ArticleGroupedConfig<TItem> {
labelKey: TranslationKey
icon: LucideIcon
promptForLang?: boolean
defaultLangsOptionKey?:
'summaryTargetLanguages' | 'translationTargetLanguages'
runTask: (input: {
refId: string
lang?: string
langs?: string[]
force?: boolean
}) => Promise<{ created: boolean; taskId: string }>
taskTypeForQueue: 'Insights' | 'Summary' | 'Translation' | 'Tts'
}
Expand Down
Loading