diff --git a/.changeset/github-import-translate.md b/.changeset/github-import-translate.md new file mode 100644 index 0000000000..869e4b8b3c --- /dev/null +++ b/.changeset/github-import-translate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Offer AI translation in Import Tasks when issue/PR content is not the dashboard language. +category: feature +dev: Adds POST /api/ai/translate-text and opt-in Translate/Show original controls in the GitHub/GitLab import preview; translation is display-only and does not change imported task text. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 444f749c5c..f2f15c7642 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -303,7 +303,7 @@ Use Import Tasks on desktop/tablet: 3. Stay on **Issues** or switch to **Pull Requests**, then optionally enter issue label filters before loading results. Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. 4. Select an issue or pull request row. - Expected outcome: the preview pane shows its title, source link, body excerpt/content, labels or PR metadata, and import availability. + Expected outcome: the preview pane shows its title, source link, body excerpt/content, labels or PR metadata, and import availability. When the selected title/body appear to be in a language other than the current dashboard language, the preview offers **Translate** (into the dashboard language), **Show original** / **Show translation** after a successful translation, and **Dismiss**. Translation is display-only in the preview; imported task text stays the original source language. 5. Select the import action. Expected outcome: Fusion creates a task (or review task for a pull request) on the board and preserves GitHub provenance/tracking metadata. After a successful issue import, the issue selection clears and the view returns to the main issue list/no-selection preview so completed issue actions do not leave stale buttons active. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 2dbe1aae79..47c7b2c66b 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6183,6 +6183,77 @@ export async function draftGoalDescription(title: string, projectId?: string): P return response.description; } +/* +FNXC:GitHubImportTranslate 2026-07-14-12:00: +Client for POST /api/ai/translate-text — used by the GitHub/GitLab import preview when issue/PR prose is not the dashboard language. +Structured title+body fields keep markdown import content intact; shares the AI-helper rate-limit budget with refine/draft. +*/ +export interface TranslateImportFields { + title?: string; + body?: string; +} + +export interface TranslateImportContentResponse { + fields: TranslateImportFields; +} + +/** + * Translate import-preview title/body into the dashboard locale via AI. + * @param fields - Original title and/or body + * @param targetLocale - Active dashboard locale + * @param projectId - Optional project scope for settings/MCP + * @param sourceLocale - Optional detection hint for the model + */ +export async function translateImportContent( + fields: TranslateImportFields, + targetLocale: string, + projectId?: string, + sourceLocale?: string, +): Promise { + const response = await api( + withProjectId("/ai/translate-text", projectId), + { + method: "POST", + body: JSON.stringify({ + fields, + targetLocale, + ...(sourceLocale ? { sourceLocale } : {}), + }), + }, + ); + return response.fields; +} + +/** User-facing error copy for translateImportContent failures (toast/banner). */ +export const TRANSLATE_ERROR_MESSAGES = { + RATE_LIMIT: "Too many translation requests. Please wait an hour.", + NETWORK: "Failed to translate content. Please try again.", +} as const; + +/** + * Map a translateImportContent error to banner-safe copy. + */ +export function getTranslateErrorMessage(error: unknown): string { + if (!(error instanceof Error)) { + return TRANSLATE_ERROR_MESSAGES.NETWORK; + } + + const message = error.message.toLowerCase(); + if (message.includes("rate limit") || message.includes("429")) { + return TRANSLATE_ERROR_MESSAGES.RATE_LIMIT; + } + if ( + message.startsWith("fields") || + message.startsWith("text to translate") || + message.startsWith("targetlocale") || + message.includes("targetlocale must") || + message.includes("sourceLocale must") + ) { + return error.message; + } + return TRANSLATE_ERROR_MESSAGES.NETWORK; +} + export function startSubtaskBreakdown(description: string, projectId?: string): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(withProjectId("/subtasks/start-streaming", projectId), { method: "POST", diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 5ae8125fcf..e17868479f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -271,6 +271,63 @@ Transient inline toast confirming issue close. Sits directly under the preview h color: var(--color-error); } +/* +FNXC:GitHubImportTranslate 2026-07-14-12:00: +Opt-in translation banner for import preview when content language differs from the dashboard locale. +Compact row under metadata so title/body stay readable; error uses the same token language as the close toast. +*/ +.github-import-translate { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: var(--space-sm) 0; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); +} + +.github-import-translate__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +.github-import-translate__icon { + flex: 0 0 auto; + color: var(--accent); +} + +.github-import-translate__message { + flex: 1 1 12rem; + min-width: 0; + font-size: 12px; + line-height: 1.4; + color: var(--text); +} + +.github-import-translate__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); + margin-left: auto; +} + +.github-import-translate__action, +.github-import-translate__dismiss { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + white-space: nowrap; +} + +.github-import-translate__error { + font-size: 12px; + color: var(--color-error); +} + .github-import-pane-content { flex: 1; min-height: 0; diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index c555428df3..33aafd79fc 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -1,8 +1,7 @@ import "./GitHubImportModal.css"; import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; import { useTranslation } from "react-i18next"; -import type { Task } from "@fusion/core"; -import { getErrorMessage } from "@fusion/core"; +import { DEFAULT_LOCALE, getErrorMessage, isLocale, type Locale, type Task } from "@fusion/core"; import { apiFetchGitHubIssues, apiImportGitHubIssue, @@ -30,6 +29,7 @@ import { import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User } from "lucide-react"; import { GithubIcon } from "./GithubIcon"; import { MailboxMessageContent } from "./MailboxMessageContent"; +import { useGitHubImportTranslation } from "./GitHubImportTranslateControls"; import type { TFunction } from "i18next"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; @@ -319,7 +319,14 @@ The full body renders as GitHub-flavored markdown via the shared MailboxMessageC export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) { const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation); useMobileScrollLock(isOpen && scrollLockEnabled); - const { t } = useTranslation("app"); + const { t, i18n } = useTranslation("app"); + /* + FNXC:GitHubImportTranslate 2026-07-14-12:00: + Translation target is the active dashboard locale (i18n.resolvedLanguage). When content is another language, the preview offers Translate / Show original / Dismiss. + */ + const dashboardLocale: Locale = isLocale(i18n.resolvedLanguage ?? i18n.language) + ? (i18n.resolvedLanguage ?? i18n.language) as Locale + : DEFAULT_LOCALE; const [owner, setOwner] = useState(""); const [repo, setRepo] = useState(""); const [labels, setLabels] = useState(""); @@ -1202,6 +1209,43 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const selectedIssueClosed = !!selectedIssue && (selectedIssue.state === "closed" || closedIssueNumbers.has(selectedIssue.number)); + /* + FNXC:GitHubImportTranslate 2026-07-14-12:00: + One translation hook covers GitHub issues, GitHub PRs, and GitLab selections. selectionKey isolates cache/dismiss state so switching items does not show the wrong translation. + */ + const translateSelection = useMemo(() => { + if (provider === "gitlab" && selectedGitlabItem) { + return { + key: `gitlab:${selectedGitlabKey ?? ""}`, + title: selectedGitlabItem.title ?? "", + body: selectedGitlabItem.description ?? "", + }; + } + if (provider === "github" && activeTab === "issues" && selectedIssue) { + return { + key: `issue:${selectedIssue.number}`, + title: selectedIssue.title ?? "", + body: selectedIssue.body ?? "", + }; + } + if (provider === "github" && activeTab === "pulls" && selectedPull) { + return { + key: `pull:${selectedPull.number}`, + title: selectedPull.title ?? "", + body: selectedPull.body ?? "", + }; + } + return { key: null as string | null, title: "", body: "" }; + }, [provider, selectedGitlabItem, selectedGitlabKey, activeTab, selectedIssue, selectedPull]); + + const importTranslation = useGitHubImportTranslation({ + selectionKey: translateSelection.key, + title: translateSelection.title, + body: translateSelection.body, + dashboardLocale, + projectId, + }); + if (!isOpen) return null; // Determine state flags @@ -1641,7 +1685,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {activeTab === "issues" && selectedIssue ? (
{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}
-
{selectedIssue.title}
+
{importTranslation.display.title}
{/* FNXC:GitHubImport 2026-06-23-03:15: Badge reflects the local close (closedIssueNumbers) so closing the issue flips it to "closed" without a refetch. */} {(() => { @@ -1664,10 +1708,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, ))} )} - {selectedIssue.body ? ( + {/* + FNXC:GitHubImportTranslate 2026-07-14-12:00: + Translate banner appears only when detected content language differs from the dashboard locale. Displayed title/body swap between original and AI translation without changing what gets imported. + */} + {importTranslation.controls} + {importTranslation.display.body ? ( ) : ( @@ -1710,7 +1759,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {activeTab === "pulls" && selectedPull ? (
{t("git.previewPullMeta", "Pull Request #{{number}}", { number: selectedPull.number })}
-
{selectedPull.title}
+
{importTranslation.display.title}
{selectedPull.state && ( {selectedPull.state} @@ -1725,10 +1774,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{t("git.branchLabel", "Branch:")} {selectedPull.headBranch} → {selectedPull.baseBranch}
- {selectedPull.body ? ( + {/* FNXC:GitHubImportTranslate 2026-07-14-12:00: Same opt-in translate banner as the issue preview (title + body only; comments stay original). */} + {importTranslation.controls} + {importTranslation.display.body ? ( ) : ( @@ -1843,9 +1894,11 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{selectedGitlabItem ? (
-

{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {selectedGitlabItem.title}

+

{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {importTranslation.display.title}

{selectedGitlabItem.state}{t("git.openSource", "Open source")}
- + {/* FNXC:GitHubImportTranslate 2026-07-14-12:00: GitLab import preview reuses the same language-detect + translate controls as GitHub. */} + {importTranslation.controls} +
) :
{t("git.gitlabNoSelection", "No GitLab resource selected")}{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}
} diff --git a/packages/dashboard/app/components/GitHubImportTranslateControls.tsx b/packages/dashboard/app/components/GitHubImportTranslateControls.tsx new file mode 100644 index 0000000000..e62e85326f --- /dev/null +++ b/packages/dashboard/app/components/GitHubImportTranslateControls.tsx @@ -0,0 +1,292 @@ +/* +FNXC:GitHubImportTranslate 2026-07-14-12:00: +Import Tasks preview shows translation controls only when selected issue/PR prose is not the dashboard language. +Operators can translate title+body into the active UI locale, toggle original vs translated, or dismiss the offer for the current selection. +Translation is opt-in (never automatic) so import provenance stays faithful until the operator asks. +*/ + +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Languages, Loader2 } from "lucide-react"; +import type { Locale } from "@fusion/core"; +import { translateImportContent, getTranslateErrorMessage } from "../api"; +import { + contentNeedsTranslation, + localeDisplayName, + type DetectedContentLanguage, +} from "../utils/detectContentLanguage"; + +export type ImportTranslateFields = { + title: string; + body: string; +}; + +export type ImportTranslateView = { + /** Fields currently shown in the preview (original or translated). */ + display: ImportTranslateFields; + /** True when a foreign-language offer/banner should render. */ + showControls: boolean; + /** Controls UI element for the banner/toggle row. */ + controls: ReactNode; + /** Whether the preview is currently showing the translated fields. */ + showingTranslation: boolean; +}; + +export interface UseGitHubImportTranslationArgs { + /** Stable key for the selected item (e.g. `issue:12` / `pull:3` / `gitlab:…`). */ + selectionKey: string | null; + title: string; + body: string; + dashboardLocale: Locale; + projectId?: string; +} + +/** + * Hook + controls for optional AI translation of import-preview title/body. + * Caches per-selection translations so re-selecting does not re-bill the AI helper. + */ +export function useGitHubImportTranslation({ + selectionKey, + title, + body, + dashboardLocale, + projectId, +}: UseGitHubImportTranslationArgs): ImportTranslateView { + const { t } = useTranslation("app"); + const original = useMemo( + () => ({ title: title ?? "", body: body ?? "" }), + [title, body], + ); + + const detectText = useMemo( + () => [original.title, original.body].filter(Boolean).join("\n\n"), + [original.title, original.body], + ); + + const needs = useMemo( + () => contentNeedsTranslation(detectText, dashboardLocale), + [detectText, dashboardLocale], + ); + + const [dismissedKeys, setDismissedKeys] = useState>(() => new Set()); + const [cache, setCache] = useState>(() => new Map()); + const [showingTranslation, setShowingTranslation] = useState(false); + const [translating, setTranslating] = useState(false); + const [error, setError] = useState(null); + + // Reset view mode when selection changes; keep cache and dismissals. + useEffect(() => { + setShowingTranslation(false); + setError(null); + setTranslating(false); + }, [selectionKey]); + + const cached = selectionKey ? cache.get(selectionKey) : undefined; + const dismissed = selectionKey ? dismissedKeys.has(selectionKey) : true; + + const showControls = Boolean( + selectionKey && + needs.needed && + !dismissed && + (original.title.trim() || original.body.trim()), + ); + + const display: ImportTranslateFields = + showingTranslation && cached + ? cached + : original; + + const handleTranslate = useCallback(async () => { + if (!selectionKey || translating) return; + setError(null); + + const existing = cache.get(selectionKey); + if (existing) { + setShowingTranslation(true); + return; + } + + setTranslating(true); + try { + const fields = await translateImportContent( + { + title: original.title, + body: original.body, + }, + dashboardLocale, + projectId, + needs.detected.locale !== "unknown" ? needs.detected.locale : undefined, + ); + const next: ImportTranslateFields = { + title: fields.title ?? original.title, + body: fields.body ?? original.body, + }; + setCache((prev) => { + const copy = new Map(prev); + copy.set(selectionKey, next); + return copy; + }); + setShowingTranslation(true); + } catch (err) { + setError(getTranslateErrorMessage(err)); + } finally { + setTranslating(false); + } + }, [ + selectionKey, + translating, + cache, + original.title, + original.body, + dashboardLocale, + projectId, + needs.detected.locale, + ]); + + const handleToggle = useCallback(() => { + setShowingTranslation((prev) => !prev); + }, []); + + const handleDismiss = useCallback(() => { + if (!selectionKey) return; + setDismissedKeys((prev) => { + const copy = new Set(prev); + copy.add(selectionKey); + return copy; + }); + setShowingTranslation(false); + setError(null); + }, [selectionKey]); + + const controls = showControls ? ( + + ) : null; + + return { + display, + showControls, + controls, + showingTranslation: Boolean(showingTranslation && cached), + }; +} + +interface ControlsProps { + detected: DetectedContentLanguage; + dashboardLocale: Locale; + translating: boolean; + hasTranslation: boolean; + showingTranslation: boolean; + error: string | null; + onTranslate: () => void; + onToggle: () => void; + onDismiss: () => void; + t: (key: string, defaultValue: string, options?: Record) => string; +} + +function GitHubImportTranslateControls({ + detected, + dashboardLocale, + translating, + hasTranslation, + showingTranslation, + error, + onTranslate, + onToggle, + onDismiss, + t, +}: ControlsProps) { + const sourceLabel = + detected.locale === "unknown" + ? t("git.translateUnknownLanguage", "another language") + : localeDisplayName(detected.locale); + const targetLabel = localeDisplayName(dashboardLocale); + + return ( +
+
+
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 24386d9058..ac623fd67b 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -17,6 +17,7 @@ import { apiImportGitLabMergeRequest, fetchSettings, fetchGitRemotes, + translateImportContent, } from "../../api"; import type { Task } from "@fusion/core"; import type { GitRemote } from "../../api"; @@ -43,6 +44,7 @@ vi.mock("../../api", async (importOriginal) => { apiImportGitLabMergeRequest: vi.fn(), fetchSettings: vi.fn(), fetchGitRemotes: vi.fn(), + translateImportContent: vi.fn(), }; }); @@ -473,6 +475,78 @@ describe("GitHubImportModal", () => { expect(screen.queryByTestId("github-import-preview-empty")).toBeNull(); }); + /* + FNXC:GitHubImportTranslate 2026-07-14-12:00: + When selected issue prose is not the dashboard language, the preview must offer Translate / Dismiss and swap title+body after a successful AI translation without changing import provenance. + */ + it("offers translation when selected issue content is not the dashboard language", async () => { + const frenchBody = + "Cette issue décrit le problème avec l'aperçu d'importation et ce que nous devrions changer pour les utilisateurs qui ont du contenu dans une autre langue dans le tableau de bord."; + const issues = [ + { + number: 7, + title: "Problème d'aperçu d'importation", + body: frenchBody, + html_url: "https://github.com/owner/repo/issues/7", + labels: [], + }, + ]; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues); + vi.mocked(translateImportContent).mockResolvedValueOnce({ + title: "Import preview problem", + body: "This issue describes the import preview problem and what we should change for users who have content in another language in the dashboard.", + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Problème d'aperçu/)).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("radio", { name: /Select issue #7/i })); + + const translateRegion = await screen.findByTestId("github-import-translate"); + expect(translateRegion).toBeTruthy(); + expect(screen.getByTestId("github-import-translate-action")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("github-import-translate-action")); + + await waitFor(() => { + expect(translateImportContent).toHaveBeenCalled(); + expect(screen.getByText("Import preview problem")).toBeTruthy(); + }); + + expect(screen.getByTestId("github-import-translate-toggle")).toBeTruthy(); + fireEvent.click(screen.getByTestId("github-import-translate-toggle")); + const previewCard = screen.getByTestId("github-import-preview-card"); + expect(within(previewCard).getByText(/Problème d'aperçu d'importation/)).toBeTruthy(); + }); + + it("does not show translate controls for English content when dashboard language is English", async () => { + const issues = [ + { + number: 8, + title: "Import preview problem", + body: "This issue describes the problem with the import preview and what we should change for the users that have content in another language when they open the dashboard.", + html_url: "https://github.com/owner/repo/issues/8", + labels: [], + }, + ]; + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import preview problem")).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("radio", { name: /Select issue #8/i })); + await screen.findByTestId("github-import-preview-card"); + expect(screen.queryByTestId("github-import-translate")).toBeNull(); + }); + it("preserves the no-description fallback for empty and null issue bodies", async () => { const issues = [ { number: 1, title: "Empty Issue", body: "", html_url: "https://github.com/owner/repo/issues/1", labels: [] }, diff --git a/packages/dashboard/app/utils/__tests__/detectContentLanguage.test.ts b/packages/dashboard/app/utils/__tests__/detectContentLanguage.test.ts new file mode 100644 index 0000000000..8469d425ed --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/detectContentLanguage.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { + contentNeedsTranslation, + detectContentLanguage, + localeDisplayName, + MIN_DETECTABLE_CHARS, +} from "../detectContentLanguage"; + +describe("detectContentLanguage", () => { + it("returns unknown for empty or too-short text", () => { + expect(detectContentLanguage("").locale).toBe("unknown"); + expect(detectContentLanguage("hi").confidence).toBe("low"); + expect(detectContentLanguage("a".repeat(MIN_DETECTABLE_CHARS - 1)).locale).toBe("unknown"); + }); + + it("detects Korean Hangul prose", () => { + const text = + "이 이슈는 대시보드의 가져오기 미리보기에서 번역 옵션을 제공하기 위한 테스트 본문입니다. 사용자가 다른 언어로 작성된 내용을 읽을 수 있어야 합니다."; + const detected = detectContentLanguage(text); + expect(detected.locale).toBe("ko"); + expect(detected.family).toBe("hangul"); + expect(detected.confidence).not.toBe("low"); + }); + + it("detects Chinese CJK prose", () => { + const text = + "这个议题描述了导入预览中的翻译功能需求。当内容语言与仪表盘语言不同时,应该向用户提供翻译选项,以便他们理解问题标题和正文。"; + const detected = detectContentLanguage(text); + expect(detected.family).toBe("cjk"); + expect(detected.locale).toBe("zh-CN"); + }); + + it("detects English stopword-heavy prose", () => { + const text = + "This issue describes the problem with the import preview and what we should change for the users that have content in another language when they open the dashboard."; + const detected = detectContentLanguage(text); + expect(detected.locale).toBe("en"); + expect(detected.family).toBe("latin"); + }); + + it("detects French stopword-heavy prose", () => { + const text = + "Cette issue décrit le problème avec l'aperçu d'importation et ce que nous devrions changer pour les utilisateurs qui ont du contenu dans une autre langue dans le tableau de bord."; + const detected = detectContentLanguage(text); + expect(detected.locale).toBe("fr"); + expect(detected.family).toBe("latin"); + }); + + it("detects Spanish stopword-heavy prose", () => { + const text = + "Este problema describe el fallo con la vista previa de importación y lo que deberíamos cambiar para los usuarios que tienen contenido en otro idioma cuando abren el panel."; + const detected = detectContentLanguage(text); + expect(detected.locale).toBe("es"); + expect(detected.family).toBe("latin"); + }); + + it("ignores fenced code and URLs when scoring", () => { + const text = ` +## Bug +This issue describes the problem with the import preview and what we should change for the users. + +\`\`\`ts +const hangul = "이것은 코드입니다"; +\`\`\` + +See https://github.com/owner/repo/issues/1 for context about the users. +`; + const detected = detectContentLanguage(text); + expect(detected.locale).toBe("en"); + }); +}); + +describe("contentNeedsTranslation", () => { + const french = + "Cette issue décrit le problème avec l'aperçu d'importation et ce que nous devrions changer pour les utilisateurs qui ont du contenu dans une autre langue dans le tableau de bord."; + const english = + "This issue describes the problem with the import preview and what we should change for the users that have content in another language when they open the dashboard."; + const korean = + "이 이슈는 대시보드의 가져오기 미리보기에서 번역 옵션을 제공하기 위한 테스트 본문입니다. 사용자가 다른 언어로 작성된 내용을 읽을 수 있어야 합니다."; + const chinese = + "这个议题描述了导入预览中的翻译功能需求。当内容语言与仪表盘语言不同时,应该向用户提供翻译选项,以便他们理解问题标题和正文。"; + + it("does not offer translation when content matches dashboard locale", () => { + expect(contentNeedsTranslation(english, "en").needed).toBe(false); + expect(contentNeedsTranslation(french, "fr").needed).toBe(false); + expect(contentNeedsTranslation(korean, "ko").needed).toBe(false); + }); + + it("offers translation when content language differs from dashboard locale", () => { + expect(contentNeedsTranslation(french, "en").needed).toBe(true); + expect(contentNeedsTranslation(korean, "en").needed).toBe(true); + expect(contentNeedsTranslation(english, "ko").needed).toBe(true); + }); + + it("does not offer Chinese translation when dashboard is either Chinese locale", () => { + expect(contentNeedsTranslation(chinese, "zh-CN").needed).toBe(false); + expect(contentNeedsTranslation(chinese, "zh-TW").needed).toBe(false); + }); + + it("offers translation for Chinese content when dashboard is English", () => { + expect(contentNeedsTranslation(chinese, "en").needed).toBe(true); + }); +}); + +describe("localeDisplayName", () => { + it("returns endonyms for supported locales", () => { + expect(localeDisplayName("en")).toBe("English"); + expect(localeDisplayName("ko")).toBe("한국어"); + expect(localeDisplayName("fr")).toBe("Français"); + }); +}); diff --git a/packages/dashboard/app/utils/detectContentLanguage.ts b/packages/dashboard/app/utils/detectContentLanguage.ts new file mode 100644 index 0000000000..17b0ec1fed --- /dev/null +++ b/packages/dashboard/app/utils/detectContentLanguage.ts @@ -0,0 +1,233 @@ +/* +FNXC:GitHubImportTranslate 2026-07-14-12:00: +The GitHub (and GitLab) import preview must offer translation only when selected issue/PR content is in a different language than the active dashboard locale. +Client-side detection is heuristic (Unicode script counts + Latin stopword scoring) so the banner can appear without an AI round-trip; uncertain or same-family content stays silent rather than spamming a false-positive translate CTA. +*/ + +import type { Locale } from "@fusion/core"; +import { SUPPORTED_LOCALES } from "@fusion/core"; + +/** Minimum alphabetic characters before we attempt language detection. */ +export const MIN_DETECTABLE_CHARS = 24; + +/** + * Script/language families used for mismatch decisions. + * zh-CN and zh-TW share `cjk` so Chinese content does not prompt translation when the UI is either Chinese locale. + * Latin locales (en/fr/es) share `latin` at the script layer and are disambiguated via stopword scores. + */ +export type LanguageFamily = "latin" | "cjk" | "hangul" | "other"; + +export type DetectedContentLanguage = { + /** Best-effort BCP-47-ish code among supported locales, or `unknown` when confidence is too low. */ + locale: Locale | "unknown"; + family: LanguageFamily; + /** Relative confidence of the best guess. */ + confidence: "high" | "medium" | "low"; +}; + +const LATIN_STOPWORDS: Record<"en" | "fr" | "es", readonly string[]> = { + en: [ + "the", "and", "for", "that", "with", "this", "from", "have", "will", "are", + "not", "but", "you", "all", "can", "has", "was", "were", "been", "their", + "which", "when", "what", "into", "about", "would", "there", "should", + ], + fr: [ + "les", "des", "une", "est", "dans", "pour", "que", "qui", "sur", "avec", + "pas", "plus", "par", "sont", "cette", "aussi", "comme", "mais", "nous", + "vous", "être", "fait", "tout", "leur", "entre", "sans", "après", + ], + es: [ + "los", "las", "del", "una", "que", "por", "con", "para", "como", "más", + "este", "esta", "está", "son", "pero", "sus", "sobre", "entre", "cuando", + "también", "después", "desde", "hasta", "sin", "todos", "puede", + ], +}; + +function countMatches(text: string, re: RegExp): number { + const matches = text.match(re); + return matches?.length ?? 0; +} + +function familyForLocale(locale: Locale): LanguageFamily { + if (locale === "ko") return "hangul"; + if (locale === "zh-CN" || locale === "zh-TW") return "cjk"; + return "latin"; +} + +function isSupportedLocale(value: string): value is Locale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** + * Score Latin text against en/fr/es stopword lists. + * Returns the best locale and a confidence derived from score separation. + */ +function scoreLatinLocale(text: string): { locale: Locale; confidence: DetectedContentLanguage["confidence"] } { + const tokens = text + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .split(/[^a-zàâäéèêëïîôùûüçñ]+/i) + .filter((t) => t.length >= 2); + + if (tokens.length < 6) { + return { locale: "en", confidence: "low" }; + } + + const scores: Record<"en" | "fr" | "es", number> = { en: 0, fr: 0, es: 0 }; + for (const token of tokens) { + for (const locale of ["en", "fr", "es"] as const) { + if (LATIN_STOPWORDS[locale].includes(token)) { + scores[locale] += 1; + } + } + } + + const ranked = (Object.entries(scores) as Array<["en" | "fr" | "es", number]>).sort( + (a, b) => b[1] - a[1], + ); + const [best, second] = ranked; + const bestScore = best[1]; + const secondScore = second[1]; + + if (bestScore === 0) { + return { locale: "en", confidence: "low" }; + } + + const ratio = secondScore === 0 ? Infinity : bestScore / secondScore; + const confidence: DetectedContentLanguage["confidence"] = + bestScore >= 4 && ratio >= 1.6 ? "high" : bestScore >= 2 && ratio >= 1.25 ? "medium" : "low"; + + return { locale: best[0], confidence }; +} + +/** + * Detect the likely language of free-form issue/PR content for import-preview translation gating. + * Intentionally conservative: short, code-heavy, or ambiguous samples return `unknown` / low confidence. + */ +export function detectContentLanguage(text: string): DetectedContentLanguage { + const sample = (text ?? "").trim(); + if (!sample) { + return { locale: "unknown", family: "other", confidence: "low" }; + } + + // Strip fenced code, URLs, and GitHub usernames so detection focuses on prose. + const cleaned = sample + .replace(/```[\s\S]*?```/g, " ") + .replace(/`[^`]+`/g, " ") + .replace(/https?:\/\/\S+/gi, " ") + .replace(/@[\w-]+/g, " ") + .replace(/#\d+/g, " "); + + const hangul = countMatches(cleaned, /[\uAC00-\uD7AF]/g); + const hiraganaKatakana = countMatches(cleaned, /[\u3040-\u30FF]/g); + const cjk = countMatches(cleaned, /[\u4E00-\u9FFF]/g); + const latin = countMatches(cleaned, /[A-Za-zÀ-ÖØ-öø-ÿ]/g); + const letters = hangul + hiraganaKatakana + cjk + latin; + + if (letters < MIN_DETECTABLE_CHARS) { + return { locale: "unknown", family: "other", confidence: "low" }; + } + + const hangulShare = hangul / letters; + const cjkShare = cjk / letters; + const latinShare = latin / letters; + + if (hangulShare >= 0.35) { + return { + locale: "ko", + family: "hangul", + confidence: hangulShare >= 0.55 ? "high" : "medium", + }; + } + + // Japanese (hiragana/katakana present) is not a dashboard locale — treat as non-matching CJK family. + if (hiraganaKatakana >= 8 || (hiraganaKatakana >= 3 && cjkShare >= 0.2)) { + return { locale: "unknown", family: "cjk", confidence: "high" }; + } + + if (cjkShare >= 0.35) { + // Cannot reliably split zh-CN vs zh-TW without a dictionary; either Chinese UI locale + // should suppress the translate CTA for CJK prose. + return { + locale: "zh-CN", + family: "cjk", + confidence: cjkShare >= 0.55 ? "high" : "medium", + }; + } + + if (latinShare >= 0.55) { + const latinGuess = scoreLatinLocale(cleaned); + return { + locale: latinGuess.locale, + family: "latin", + confidence: latinGuess.confidence, + }; + } + + return { locale: "unknown", family: "other", confidence: "low" }; +} + +/** + * Whether import-preview content should offer translation into `dashboardLocale`. + * Requires medium+ confidence and a family/locale mismatch so we do not nag same-language content. + */ +export function contentNeedsTranslation( + text: string, + dashboardLocale: Locale, +): { needed: boolean; detected: DetectedContentLanguage } { + const detected = detectContentLanguage(text); + if (detected.confidence === "low" || detected.locale === "unknown") { + // Still offer when family is clearly foreign (e.g. Japanese kana) even if locale is unknown. + if (detected.confidence === "high" && detected.family !== familyForLocale(dashboardLocale) && detected.family !== "other") { + return { needed: true, detected }; + } + return { needed: false, detected }; + } + + if (detected.locale === dashboardLocale) { + return { needed: false, detected }; + } + + // Chinese UI locales treat Simplified/Traditional detection as same family. + if ( + familyForLocale(dashboardLocale) === "cjk" && + detected.family === "cjk" && + isSupportedLocale(detected.locale) && + familyForLocale(detected.locale) === "cjk" + ) { + return { needed: false, detected }; + } + + // Latin locales that share the same stopword winner as dashboard. + if (detected.locale === dashboardLocale) { + return { needed: false, detected }; + } + + // Require medium+ confidence for same-script (latin) mismatches to limit false positives. + if (detected.family === familyForLocale(dashboardLocale) && detected.confidence !== "high") { + return { needed: false, detected }; + } + + return { needed: true, detected }; +} + +/** Human-readable endonym for a detected/source locale chip in the translate banner. */ +export function localeDisplayName(locale: Locale | "unknown"): string { + switch (locale) { + case "en": + return "English"; + case "zh-CN": + return "简体中文"; + case "zh-TW": + return "繁體中文"; + case "fr": + return "Français"; + case "es": + return "Español"; + case "ko": + return "한국어"; + default: + return locale; + } +} diff --git a/packages/dashboard/src/__tests__/ai-translate.test.ts b/packages/dashboard/src/__tests__/ai-translate.test.ts new file mode 100644 index 0000000000..f66607abe0 --- /dev/null +++ b/packages/dashboard/src/__tests__/ai-translate.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + validateTranslateRequest, + parseTranslateResponse, + translateText, + MAX_TRANSLATE_TEXT_LENGTH, + MIN_TRANSLATE_TEXT_LENGTH, + checkRateLimit, + ValidationError, + AiServiceError, +} from "../ai-translate.js"; +import { __resetRefineState } from "../ai-refine.js"; + +const { mockCreateFnAgent, mockResolveMcpServersForStore } = vi.hoisted(() => ({ + mockCreateFnAgent: vi.fn(), + mockResolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), +})); + +vi.mock("@fusion/engine", () => ({ + listCliAdapterDescriptors: () => [], + createFnAgent: mockCreateFnAgent, + resolveMcpServersForStore: mockResolveMcpServersForStore, +})); + +function mockAgentWithAssistantText(text: string) { + mockCreateFnAgent.mockResolvedValue({ + session: { + state: { messages: [{ role: "assistant", content: text }] }, + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + }, + }); +} + +describe("ai-translate module", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + __resetRefineState(); + vi.clearAllMocks(); + mockCreateFnAgent.mockResolvedValue(null); + mockResolveMcpServersForStore.mockResolvedValue({ servers: [], errors: [] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("validateTranslateRequest", () => { + it("accepts title and body with a supported target locale", () => { + const result = validateTranslateRequest( + { title: "Bonjour", body: "Ceci est un test" }, + "en", + ); + expect(result).toEqual({ + fields: { title: "Bonjour", body: "Ceci est un test" }, + targetLocale: "en", + sourceLocale: undefined, + }); + }); + + it("accepts title-only or body-only fields", () => { + expect(validateTranslateRequest({ title: "Hello world" }, "fr").fields).toEqual({ + title: "Hello world", + }); + expect(validateTranslateRequest({ body: "Hello world body text" }, "es").fields).toEqual({ + body: "Hello world body text", + }); + }); + + it("rejects missing fields object", () => { + expect(() => validateTranslateRequest(null, "en")).toThrow(ValidationError); + expect(() => validateTranslateRequest(undefined, "en")).toThrow("fields is required"); + }); + + it("rejects empty combined text", () => { + expect(() => validateTranslateRequest({ title: " ", body: "" }, "en")).toThrow( + ValidationError, + ); + }); + + it("rejects oversized text", () => { + expect(() => + validateTranslateRequest({ body: "x".repeat(MAX_TRANSLATE_TEXT_LENGTH + 1) }, "en"), + ).toThrow(`must not exceed ${MAX_TRANSLATE_TEXT_LENGTH}`); + }); + + it("rejects invalid targetLocale", () => { + expect(() => validateTranslateRequest({ title: "Hi" }, "de")).toThrow(ValidationError); + expect(() => validateTranslateRequest({ title: "Hi" }, "de")).toThrow("targetLocale must be"); + }); + + it("accepts optional sourceLocale hint", () => { + const result = validateTranslateRequest({ title: "Hola" }, "en", "es"); + expect(result.sourceLocale).toBe("es"); + }); + + it("exports a positive min length constant", () => { + expect(MIN_TRANSLATE_TEXT_LENGTH).toBeGreaterThan(0); + }); + }); + + describe("parseTranslateResponse", () => { + it("parses a plain JSON object", () => { + const fields = parseTranslateResponse( + JSON.stringify({ title: "Hello", body: "World" }), + { title: "Bonjour", body: "Monde" }, + ); + expect(fields).toEqual({ title: "Hello", body: "World" }); + }); + + it("strips markdown fences around JSON", () => { + const fields = parseTranslateResponse( + "```json\n{\"title\":\"Hello\",\"body\":\"World\"}\n```", + { title: "Bonjour", body: "Monde" }, + ); + expect(fields).toEqual({ title: "Hello", body: "World" }); + }); + + it("falls back to original title when model omits it", () => { + const fields = parseTranslateResponse( + JSON.stringify({ body: "Only body" }), + { title: "Original title", body: "Original body" }, + ); + expect(fields.title).toBe("Original title"); + expect(fields.body).toBe("Only body"); + }); + + it("throws AiServiceError for non-JSON", () => { + expect(() => parseTranslateResponse("not json at all", { title: "t" })).toThrow( + AiServiceError, + ); + }); + }); + + describe("translateText", () => { + it("returns translated fields from the AI agent", async () => { + mockAgentWithAssistantText(JSON.stringify({ title: "Hello", body: "This is a test" })); + + const result = await translateText( + { + fields: { title: "Bonjour", body: "Ceci est un test" }, + targetLocale: "en", + sourceLocale: "fr", + }, + "/tmp/project", + ); + + expect(result).toEqual({ title: "Hello", body: "This is a test" }); + expect(mockCreateFnAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/tmp/project", + tools: "readonly", + }), + ); + }); + + it("throws when the AI engine is unavailable", async () => { + mockCreateFnAgent.mockResolvedValueOnce(null); + await expect( + translateText( + { fields: { title: "Bonjour" }, targetLocale: "en" }, + "/tmp/project", + ), + ).rejects.toThrow("Failed to initialize AI agent"); + }); + + it("shares the refine rate-limit helper", () => { + // Sanity: re-exported checkRateLimit is callable (shared budget with refine). + expect(checkRateLimit("10.0.0.1")).toBe(true); + }); + }); +}); diff --git a/packages/dashboard/src/ai-translate.ts b/packages/dashboard/src/ai-translate.ts new file mode 100644 index 0000000000..894f246444 --- /dev/null +++ b/packages/dashboard/src/ai-translate.ts @@ -0,0 +1,315 @@ +/** + * AI Text Translation Service + * + * Translates free-form GitHub/GitLab import preview content (title + body) + * into the operator's dashboard locale. Mirrors the readonly AI helper pattern + * used by ai-refine (rate limit, createFnAgent, MCP resolution). + * + * FNXC:GitHubImportTranslate 2026-07-14-12:00: + * Import Tasks preview needs on-demand translation when issue/PR prose is not the active UI language. + * Accept structured title+body fields (not a single blob) so markdown structure and headings survive, + * allow longer bodies than refine-text (issue descriptions often exceed 2k chars), and return JSON fields only. + */ + +import type { Locale, PromptOverrideMap, TaskStore } from "@fusion/core"; +import { isLocale, SUPPORTED_LOCALES } from "@fusion/core"; +import { createFnAgent as engineCreateFnAgent, resolveMcpServersForStore } from "@fusion/engine"; +import { + checkRateLimit, + getRateLimitResetTime, + AiServiceError, + ValidationError, +} from "./ai-refine.js"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createFnAgent: any = engineCreateFnAgent; + +function ensureEngineReady(): Promise { + return Promise.resolve(); +} + +/** Re-export shared AI helper rate-limit so routes share the refine/translate budget. */ +export { checkRateLimit, getRateLimitResetTime, AiServiceError, ValidationError }; + +/** Maximum combined characters accepted for translation (title + body). */ +export const MAX_TRANSLATE_TEXT_LENGTH = 12000; + +/** Soft cap fed to the model; longer inputs are truncated with a marker. */ +export const MAX_TRANSLATE_MODEL_INPUT_LENGTH = 8000; + +/** Minimum non-empty text across fields. */ +export const MIN_TRANSLATE_TEXT_LENGTH = 1; + +export interface TranslateFields { + title?: string; + body?: string; +} + +export interface TranslateTextRequest { + fields: TranslateFields; + targetLocale: Locale; + sourceLocale?: string; +} + +export interface TranslateTextResponse { + fields: TranslateFields; +} + +export const TRANSLATE_SYSTEM_PROMPT = `You are a translation assistant for a software task board. + +Translate the provided JSON fields into the requested target language. + +## Rules +- Preserve markdown structure (headings, lists, code fences, links, tables). +- Do NOT translate code inside fenced code blocks or inline backticks. +- Do NOT translate URLs, issue numbers (#123), @mentions, or file paths. +- Keep the same fields that were provided; omit fields that were missing/empty. +- Output ONLY a JSON object with the translated fields (keys: "title", "body"). No preamble, no markdown fence around the JSON. +- If a field is already in the target language, return it unchanged.`; + +const LOCALE_LABELS: Record = { + en: "English", + "zh-CN": "Simplified Chinese (简体中文)", + "zh-TW": "Traditional Chinese (繁體中文)", + fr: "French (Français)", + es: "Spanish (Español)", + ko: "Korean (한국어)", +}; + +/** + * Validate translation request body. + * Throws ValidationError for invalid input. + */ +export function validateTranslateRequest( + fields: unknown, + targetLocale: unknown, + sourceLocale?: unknown, +): TranslateTextRequest { + if (fields === undefined || fields === null || typeof fields !== "object" || Array.isArray(fields)) { + throw new ValidationError("fields is required and must be an object"); + } + + const raw = fields as Record; + const title = typeof raw.title === "string" ? raw.title : undefined; + const body = typeof raw.body === "string" ? raw.body : undefined; + + if (title === undefined && body === undefined) { + throw new ValidationError("fields must include a string title and/or body"); + } + + if (title !== undefined && typeof raw.title !== "string") { + throw new ValidationError("fields.title must be a string"); + } + if (body !== undefined && typeof raw.body !== "string") { + throw new ValidationError("fields.body must be a string"); + } + + const combined = `${title ?? ""}\n${body ?? ""}`.trim(); + if (combined.length < MIN_TRANSLATE_TEXT_LENGTH) { + throw new ValidationError("text to translate must not be empty"); + } + if (combined.length > MAX_TRANSLATE_TEXT_LENGTH) { + throw new ValidationError( + `text to translate must not exceed ${MAX_TRANSLATE_TEXT_LENGTH} characters`, + ); + } + + if (typeof targetLocale !== "string" || !isLocale(targetLocale)) { + throw new ValidationError( + `targetLocale must be one of: ${SUPPORTED_LOCALES.join(", ")}`, + ); + } + + let normalizedSource: string | undefined; + if (sourceLocale !== undefined && sourceLocale !== null) { + if (typeof sourceLocale !== "string") { + throw new ValidationError("sourceLocale must be a string when provided"); + } + normalizedSource = sourceLocale.trim() || undefined; + } + + return { + fields: { + ...(title !== undefined ? { title } : {}), + ...(body !== undefined ? { body } : {}), + }, + targetLocale, + sourceLocale: normalizedSource, + }; +} + +function extractLastAssistantText(messages: unknown): string { + interface AgentMessage { + role: string; + content?: string | Array<{ type: string; text: string }>; + } + + const lastMessage = (Array.isArray(messages) ? messages : []) + .filter((message): message is AgentMessage => Boolean(message) && typeof message === "object" && "role" in message) + .filter((message) => message.role === "assistant") + .pop(); + + if (!lastMessage?.content) { + return ""; + } + + if (typeof lastMessage.content === "string") { + return lastMessage.content.trim(); + } + + if (Array.isArray(lastMessage.content)) { + return lastMessage.content + .filter((content): content is { type: "text"; text: string } => content.type === "text") + .map((content) => content.text) + .join("") + .trim(); + } + + return ""; +} + +/** + * Parse model JSON output into TranslateFields. Tolerates optional markdown fences. + */ +export function parseTranslateResponse( + raw: string, + requestFields: TranslateFields, +): TranslateFields { + const trimmed = raw.trim(); + const unfenced = trimmed + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + + let parsed: unknown; + try { + parsed = JSON.parse(unfenced); + } catch { + // Some models wrap JSON in prose — try first {...} slice. + const start = unfenced.indexOf("{"); + const end = unfenced.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + parsed = JSON.parse(unfenced.slice(start, end + 1)); + } catch { + throw new AiServiceError("AI returned non-JSON translation response"); + } + } else { + throw new AiServiceError("AI returned non-JSON translation response"); + } + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new AiServiceError("AI returned invalid translation object"); + } + + const obj = parsed as Record; + const result: TranslateFields = {}; + + if (requestFields.title !== undefined) { + if (typeof obj.title === "string" && obj.title.trim()) { + result.title = obj.title; + } else { + // Fail soft: keep original title if model omitted it. + result.title = requestFields.title; + } + } + + if (requestFields.body !== undefined) { + if (typeof obj.body === "string") { + result.body = obj.body; + } else { + result.body = requestFields.body; + } + } + + if (result.title === undefined && result.body === undefined) { + throw new AiServiceError("AI returned empty translation fields"); + } + + return result; +} + +function truncateForModel(fields: TranslateFields): TranslateFields { + const title = fields.title; + let body = fields.body; + const titleLen = title?.length ?? 0; + const bodyBudget = Math.max(0, MAX_TRANSLATE_MODEL_INPUT_LENGTH - titleLen - 32); + if (body && body.length > bodyBudget) { + body = `${body.slice(0, bodyBudget)}\n…(truncated)`; + } + return { + ...(title !== undefined ? { title } : {}), + ...(body !== undefined ? { body } : {}), + }; +} + +/** + * Translate title/body fields into the dashboard target locale via a readonly AI agent. + */ +export async function translateText( + request: TranslateTextRequest, + rootDir: string, + _promptOverrides?: PromptOverrideMap, + store?: TaskStore, +): Promise { + await ensureEngineReady(); + + if (!createFnAgent) { + throw new AiServiceError("AI engine not available"); + } + + const mcpServers = (await resolveMcpServersForStore(store ?? {})).servers; + /* + * FNXC:McpConfig 2026-07-14-12:00: + * Import-preview translation is a readonly dashboard helper. Resolve MCP from the request-scoped store like refine/goal-draft; never log secrets. + */ + const agentResult = await createFnAgent({ + cwd: rootDir, + systemPrompt: TRANSLATE_SYSTEM_PROMPT, + tools: "readonly", + mcpServers, + }); + + if (!agentResult?.session) { + throw new AiServiceError("Failed to initialize AI agent"); + } + + const modelFields = truncateForModel(request.fields); + const targetLabel = LOCALE_LABELS[request.targetLocale] ?? request.targetLocale; + const sourceHint = request.sourceLocale + ? `Source language hint: ${request.sourceLocale}\n` + : ""; + const prompt = `${sourceHint}Target language: ${targetLabel} (${request.targetLocale}) + +Fields JSON to translate: +${JSON.stringify(modelFields, null, 2)}`; + + try { + await agentResult.session.prompt(prompt); + const raw = extractLastAssistantText(agentResult.session.state.messages); + if (!raw) { + throw new AiServiceError("AI returned empty response"); + } + const translated = parseTranslateResponse(raw, request.fields); + + try { + agentResult.session.dispose?.(); + } catch { + // Ignore disposal errors + } + + return translated; + } catch (err) { + try { + agentResult.session.dispose?.(); + } catch { + // Ignore disposal errors + } + + if (err instanceof AiServiceError) { + throw err; + } + throw new AiServiceError(err instanceof Error ? err.message : "AI processing failed"); + } +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index af64e05ab3..ee7bc329ea 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -1991,6 +1991,73 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); + /** + * POST /api/ai/translate-text + * AI-powered translation for GitHub/GitLab import preview title+body. + * Body: { fields: { title?: string, body?: string }, targetLocale: string, sourceLocale?: string } + * Returns: { fields: { title?: string, body?: string } } + * + * Rate limited: shared AI-helper budget (10 requests per hour per IP with refine/draft) + * + * FNXC:GitHubImportTranslate 2026-07-14-12:00: + * Import Tasks offers on-demand translation when selected content is not the dashboard language. + */ + router.post("/ai/translate-text", async (req, res) => { + try { + const { fields, targetLocale, sourceLocale } = req.body ?? {}; + const ip = req.ip || req.socket.remoteAddress || "unknown"; + + const { store: scopedStore } = await getProjectContext(req); + const rootDir = scopedStore.getRootDir(); + const settings = await scopedStore.getSettings(); + + const { + validateTranslateRequest, + checkRateLimit, + getRateLimitResetTime, + translateText, + AiServiceError: _AiServiceErrorTranslate, + ValidationError, + } = await import("./ai-translate.js"); + + if (!checkRateLimit(ip)) { + const resetTime = getRateLimitResetTime(ip); + throw rateLimited( + `Rate limit exceeded. Maximum 10 AI helper requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`, + ); + } + + let validated; + try { + validated = validateTranslateRequest(fields, targetLocale, sourceLocale); + } catch (err) { + if (err instanceof ValidationError) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + throw err; + } + + const translated = await translateText( + validated, + rootDir, + settings.promptOverrides, + scopedStore, + ); + res.json({ fields: translated }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof Error && err.name === "RateLimitError") { + throw rateLimited(err.message); + } else if (err instanceof Error && err.name === "AiServiceError") { + rethrowAsApiError(err, "AI service error"); + } else { + rethrowAsApiError(err, "Failed to translate text"); + } + } + }); + /** * POST /api/ai/draft-goal-description * AI-powered goal description drafting from a goal title. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 56b31c4aa5..9967e96749 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -2840,6 +2840,16 @@ "tabPullRequests": "Pull Requests", "tip": "tip", "toolbarAriaLabel": "GitHub import controls", + "translateAction": "Translate", + "translateDismiss": "Dismiss", + "translateOffer": "This content appears to be in {{source}}. Translate into {{target}}?", + "translateRegionAriaLabel": "Content translation", + "translateShowingOriginal": "Showing original ({{source}}).", + "translateShowingTranslated": "Showing translation into {{target}}.", + "translateShowOriginal": "Show original", + "translateShowTranslated": "Show translation", + "translateUnknownLanguage": "another language", + "translateWorking": "Translating…", "tryDifferentFilter": "Try a different label filter or choose another repository.", "unlinkButton": "Unlink", "unresolvedMergeConflicts": "Unresolved merge conflicts", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 03e4e7329e..d5fd615a46 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1417,13 +1417,6 @@ "versionMismatchPrefix": "Tu", "versionMismatchSuffix": "Actualiza para mantenerte sincronizado." }, - "storageMigrationNotice": { - "body": "", - "dismissLabel": "", - "getHelp": "", - "getHelpLabel": "", - "title": "" - }, "cliBinary": { "binaryLabel": "", "checking": "Verificando…", @@ -2865,7 +2858,17 @@ "worktreesInUse_one": "", "worktreesInUse_other": "", "worktreesTotal_one": "", - "worktreesTotal_other": "" + "worktreesTotal_other": "", + "translateAction": "", + "translateDismiss": "", + "translateOffer": "", + "translateRegionAriaLabel": "", + "translateShowingOriginal": "", + "translateShowingTranslated": "", + "translateShowOriginal": "", + "translateShowTranslated": "", + "translateUnknownLanguage": "", + "translateWorking": "" }, "githubStarPrompt": { "body": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 3cd9303715..046bc0f13d 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1417,13 +1417,6 @@ "versionMismatchPrefix": "Votre", "versionMismatchSuffix": "Mettez à jour pour rester synchronisé." }, - "storageMigrationNotice": { - "body": "", - "dismissLabel": "", - "getHelp": "", - "getHelpLabel": "", - "title": "" - }, "cliBinary": { "binaryLabel": "", "checking": "Vérification…", @@ -2865,7 +2858,17 @@ "worktreesInUse_one": "", "worktreesInUse_other": "", "worktreesTotal_one": "", - "worktreesTotal_other": "" + "worktreesTotal_other": "", + "translateAction": "", + "translateDismiss": "", + "translateOffer": "", + "translateRegionAriaLabel": "", + "translateShowingOriginal": "", + "translateShowingTranslated": "", + "translateShowOriginal": "", + "translateShowTranslated": "", + "translateUnknownLanguage": "", + "translateWorking": "" }, "githubStarPrompt": { "body": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 6d57884154..cd0886c740 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1417,13 +1417,6 @@ "versionMismatchPrefix": "설치된", "versionMismatchSuffix": "최신 버전으로 업데이트하여 동기화를 유지하세요." }, - "storageMigrationNotice": { - "body": "", - "dismissLabel": "", - "getHelp": "", - "getHelpLabel": "", - "title": "" - }, "cliBinary": { "binaryLabel": "", "checking": "확인 중…", @@ -2865,7 +2858,17 @@ "worktreesInUse_one": "", "worktreesInUse_other": "", "worktreesTotal_one": "", - "worktreesTotal_other": "" + "worktreesTotal_other": "", + "translateAction": "", + "translateDismiss": "", + "translateOffer": "", + "translateRegionAriaLabel": "", + "translateShowingOriginal": "", + "translateShowingTranslated": "", + "translateShowOriginal": "", + "translateShowTranslated": "", + "translateUnknownLanguage": "", + "translateWorking": "" }, "githubStarPrompt": { "body": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index c3de67d296..a8b8349092 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1417,13 +1417,6 @@ "versionMismatchPrefix": "你安装的", "versionMismatchSuffix": "更新以保持同步。" }, - "storageMigrationNotice": { - "body": "", - "dismissLabel": "", - "getHelp": "", - "getHelpLabel": "", - "title": "" - }, "cliBinary": { "binaryLabel": "", "checking": "检查中…", @@ -2865,7 +2858,17 @@ "worktreesInUse_one": "", "worktreesInUse_other": "", "worktreesTotal_one": "", - "worktreesTotal_other": "" + "worktreesTotal_other": "", + "translateAction": "", + "translateDismiss": "", + "translateOffer": "", + "translateRegionAriaLabel": "", + "translateShowingOriginal": "", + "translateShowingTranslated": "", + "translateShowOriginal": "", + "translateShowTranslated": "", + "translateUnknownLanguage": "", + "translateWorking": "" }, "githubStarPrompt": { "body": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 731d8a5534..3b83479f56 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1417,13 +1417,6 @@ "versionMismatchPrefix": "你安裝的", "versionMismatchSuffix": "更新以保持同步。" }, - "storageMigrationNotice": { - "body": "", - "dismissLabel": "", - "getHelp": "", - "getHelpLabel": "", - "title": "" - }, "cliBinary": { "binaryLabel": "", "checking": "檢查中…", @@ -2865,7 +2858,17 @@ "worktreesInUse_one": "", "worktreesInUse_other": "", "worktreesTotal_one": "", - "worktreesTotal_other": "" + "worktreesTotal_other": "", + "translateAction": "", + "translateDismiss": "", + "translateOffer": "", + "translateRegionAriaLabel": "", + "translateShowingOriginal": "", + "translateShowingTranslated": "", + "translateShowOriginal": "", + "translateShowTranslated": "", + "translateUnknownLanguage": "", + "translateWorking": "" }, "githubStarPrompt": { "body": "", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index a4fc619792..866bf74b21 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -1265,6 +1265,9 @@ export default interface Resources { "create": "Create", "createButton": "Create", "createRoom": "Create room", + "currentAgentTarget": "Current agent: {{name}}", + "currentDefaultTarget": "Using the default chat target", + "currentModelTarget": "Current model: {{model}}", "delete": "Delete", "deleteConversation": "Delete conversation", "deleteConversationBody": "This action cannot be undone. All messages in this conversation will be permanently deleted.", @@ -1302,6 +1305,7 @@ export default interface Resources { "messageSentButReplyFailedDetail": "Message sent, but assistant reply failed: {{detail}}", "modeAgent": "Agent", "modeModel": "Model", + "modelAgentSection": "Model / Agent", "newChat": "New Chat", "newChatModeAgent": "Agent", "newChatModeModel": "Model", @@ -1311,6 +1315,7 @@ export default interface Resources { "noMessage": "No message", "noMessages": "No messages", "noMessagesYet": "No messages yet. Start the conversation!", + "noModelsAvailable": "No models available", "noRoomsYet": "No rooms yet.", "noSkillsAvailable": "No skills available", "noSkillsFound": "No skills found", @@ -1378,6 +1383,7 @@ export default interface Resources { "thinking": "Thinking", "thinkingLabel": "Thinking", "thinkingLevelButton": "Thinking level", + "thinkingLevelSection": "Thinking level", "thinkingStatus": "Thinking…", "toolArgsLabel": "args", "toolArgsPreview": "args: {{summary}}", @@ -2836,6 +2842,16 @@ export default interface Resources { "tabPullRequests": "Pull Requests", "tip": "tip", "toolbarAriaLabel": "GitHub import controls", + "translateAction": "Translate", + "translateDismiss": "Dismiss", + "translateOffer": "This content appears to be in {{source}}. Translate into {{target}}?", + "translateRegionAriaLabel": "Content translation", + "translateShowOriginal": "Show original", + "translateShowTranslated": "Show translation", + "translateShowingOriginal": "Showing original ({{source}}).", + "translateShowingTranslated": "Showing translation into {{target}}.", + "translateUnknownLanguage": "another language", + "translateWorking": "Translating…", "tryDifferentFilter": "Try a different label filter or choose another repository.", "unlinkButton": "Unlink", "unresolvedMergeConflicts": "Unresolved merge conflicts", @@ -5683,6 +5699,8 @@ export default interface Resources { "showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.", "suppressTheLdquoNeedsYourInputRdquoBanner": " Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. ", "taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.", + "taskPopupsBoardListOnly": "Keep task popups on their Board/List view", + "taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the Board or List view where it was opened. Switching to another view hides it without closing; returning to that view restores it in the same position. Default: disabled.", "title": "Appearance" }, "auth": {