feat(dashboard): offer AI translation in Import Tasks preview (#2128)

## Summary

Import Tasks can now offer on-demand AI translation when a selected
GitHub or GitLab issue/PR title and body appear to be in a different
language than the active dashboard locale.

- Detect foreign-language content with a conservative client heuristic
(Unicode scripts + Latin stopwords)
- Show an opt-in banner: **Translate**, then **Show original / Show
translation**, plus **Dismiss**
- Call new `POST /api/ai/translate-text` (shared AI-helper rate limit
with refine/draft)
- Translation is **display-only** in the preview; imported task text
stays the original source language

## Why

Operators working in a non-English dashboard (or reading
non-dashboard-language issues) needed a way to understand import
candidates without leaving the preview or changing what gets imported.

## Test plan

- [x] Unit tests for language detection (`detectContentLanguage`)
- [x] Unit tests for translate request validation, response parsing, and
AI agent path
- [x] GitHub import modal: French content shows translate controls;
English content does not
- [x] Dashboard typecheck clean for app + server packages
- [ ] Manual: open Import Tasks with dashboard language English, select
a French/Korean issue, translate and toggle original
- [ ] Manual: confirm Import still creates the task with original
title/body
- [ ] Manual: dismiss banner for a selection and confirm it stays
dismissed for that item

## Notes

- Comments are not translated (title + body only)
- zh-CN / zh-TW share a CJK family so Chinese content does not prompt
translation when the UI is either Chinese locale
- Secondary locale catalogs have empty placeholders for the new
`git.translate*` keys (runtime falls back to English)
This commit is contained in:
gsxdsm
2026-07-15 02:18:43 -07:00
committed by GitHub
parent 78ef3075f6
commit bc2d22df6e
19 changed files with 1547 additions and 52 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<TranslateImportFields> {
const response = await api<TranslateImportContentResponse>(
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",

View File

@@ -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;

View File

@@ -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 ? (
<div className="issue-preview" data-testid="github-import-preview-card">
<div className="preview-meta">{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}</div>
<div className="preview-title">{selectedIssue.title}</div>
<div className="preview-title">{importTranslation.display.title}</div>
<div className="preview-metadata">
{/* 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,
))}
</span>
)}
{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 ? (
<MailboxMessageContent
className="preview-body preview-body--markdown"
content={selectedIssue.body}
content={importTranslation.display.body}
testId="github-import-preview-body"
/>
) : (
@@ -1710,7 +1759,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{activeTab === "pulls" && selectedPull ? (
<div className="issue-preview" data-testid="github-import-preview-card">
<div className="preview-meta">{t("git.previewPullMeta", "Pull Request #{{number}}", { number: selectedPull.number })}</div>
<div className="preview-title">{selectedPull.title}</div>
<div className="preview-title">{importTranslation.display.title}</div>
<div className="preview-metadata">
{selectedPull.state && (
<span className={`preview-state-badge preview-state-badge--${selectedPull.state}`}>{selectedPull.state}</span>
@@ -1725,10 +1774,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="preview-branch">
<strong>{t("git.branchLabel", "Branch:")}</strong> {selectedPull.headBranch} → {selectedPull.baseBranch}
</div>
{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 ? (
<MailboxMessageContent
className="preview-body preview-body--markdown"
content={selectedPull.body}
content={importTranslation.display.body}
testId="github-import-preview-body"
/>
) : (
@@ -1843,9 +1894,11 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="github-import-preview-pane">
{selectedGitlabItem ? (
<div className="issue-preview" data-testid="gitlab-import-preview-card">
<h4>{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {selectedGitlabItem.title}</h4>
<h4>{selectedGitlabItem.resourceKind === "merge_request" ? "!" : "#"}{selectedGitlabItem.iid} {importTranslation.display.title}</h4>
<div className="preview-meta-row"><span className={`preview-state-badge preview-state-badge--${selectedGitlabItem.state}`}>{selectedGitlabItem.state}</span><a href={selectedGitlabItem.webUrl} target="_blank" rel="noopener noreferrer">{t("git.openSource", "Open source")}</a></div>
<MailboxMessageContent className="preview-body preview-body--markdown" content={selectedGitlabItem.description?.trim() || t("git.noDescription", "(no description)")} testId="gitlab-import-preview-body" />
{/* FNXC:GitHubImportTranslate 2026-07-14-12:00: GitLab import preview reuses the same language-detect + translate controls as GitHub. */}
{importTranslation.controls}
<MailboxMessageContent className="preview-body preview-body--markdown" content={importTranslation.display.body?.trim() || t("git.noDescription", "(no description)")} testId="gitlab-import-preview-body" />
<button type="button" className="btn btn-primary" onClick={handleImportGitLab} disabled={!gitlabEnabled || importing || importedUrls.has(selectedGitlabItem.webUrl)}>{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}</button>
</div>
) : <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-preview-empty"><strong>{t("git.gitlabNoSelection", "No GitLab resource selected")}</strong><span>{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}</span></div>}

View File

@@ -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<ImportTranslateFields>(
() => ({ 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<Set<string>>(() => new Set());
const [cache, setCache] = useState<Map<string, ImportTranslateFields>>(() => new Map());
const [showingTranslation, setShowingTranslation] = useState(false);
const [translating, setTranslating] = useState(false);
const [error, setError] = useState<string | null>(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 ? (
<GitHubImportTranslateControls
detected={needs.detected}
dashboardLocale={dashboardLocale}
translating={translating}
hasTranslation={Boolean(cached)}
showingTranslation={showingTranslation}
error={error}
onTranslate={handleTranslate}
onToggle={handleToggle}
onDismiss={handleDismiss}
t={t}
/>
) : 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, unknown>) => 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 (
<div
className="github-import-translate"
data-testid="github-import-translate"
role="region"
aria-label={t("git.translateRegionAriaLabel", "Content translation")}
>
<div className="github-import-translate__row">
<Languages size={14} aria-hidden="true" className="github-import-translate__icon" />
<span className="github-import-translate__message" data-testid="github-import-translate-message">
{hasTranslation && showingTranslation
? t(
"git.translateShowingTranslated",
"Showing translation into {{target}}.",
{ target: targetLabel },
)
: hasTranslation
? t(
"git.translateShowingOriginal",
"Showing original ({{source}}).",
{ source: sourceLabel },
)
: t(
"git.translateOffer",
"This content appears to be in {{source}}. Translate into {{target}}?",
{ source: sourceLabel, target: targetLabel },
)}
</span>
<div className="github-import-translate__actions">
{!hasTranslation ? (
<button
type="button"
className="btn btn-sm btn-primary github-import-translate__action"
data-testid="github-import-translate-action"
onClick={onTranslate}
disabled={translating}
>
{translating ? (
<>
<Loader2 size={14} className="spin" aria-hidden="true" />
{t("git.translateWorking", "Translating…")}
</>
) : (
t("git.translateAction", "Translate")
)}
</button>
) : (
<button
type="button"
className="btn btn-sm github-import-translate__action"
data-testid="github-import-translate-toggle"
onClick={onToggle}
disabled={translating}
>
{showingTranslation
? t("git.translateShowOriginal", "Show original")
: t("git.translateShowTranslated", "Show translation")}
</button>
)}
<button
type="button"
className="btn btn-sm github-import-translate__dismiss"
data-testid="github-import-translate-dismiss"
onClick={onDismiss}
disabled={translating}
>
{t("git.translateDismiss", "Dismiss")}
</button>
</div>
</div>
{error && (
<div className="github-import-translate__error" role="alert" data-testid="github-import-translate-error">
{error}
</div>
)}
</div>
);
}

View File

@@ -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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
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: [] },

View File

@@ -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");
});
});

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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<void> {
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<Locale, string> = {
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<string, unknown>;
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<string, unknown>;
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<TranslateFields> {
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");
}
}

View File

@@ -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.

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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 &ldquo;needs your input&rdquo; 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": {