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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## v0.9.1 - 2026-07-05

- Localized every AI surface for the selected app language (Korean/English/Japanese): the companion, inline editor AI, contextual edit, and Fact Book now build prompts and reply in the app language, including tool status text, apply results, and query outputs.
- Translated canonical outline node labels (부/장/씬, 권/화) in AI prompts and the workspace UI, so an English or Japanese UI shows Arc 1/Episode 1 or 第1巻/第1話 instead of raw Korean labels.
- Added English and Japanese keywords to the companion's intent detection so requests like "rewrite this scene" or 「書き直して」 apply changes directly, matching the Korean behavior.
- Added an English (U.S.) App Store listing.

## v0.9.0 - 2026-06-24

- Promoted desktop distribution to a public release: the macOS app is Developer ID signed and notarized through the Homebrew tap, with Windows (NSIS/MSI) and Linux (AppImage/deb/rpm) prebuilt installers published on every GitHub Release.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linetta-desktop",
"version": "0.9.0",
"version": "0.9.1",
"license": "AGPL-3.0-only",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "linetta-desktop"
version = "0.9.0"
version = "0.9.1"
edition = "2021"
rust-version = "1.78"
license = "AGPL-3.0-only"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Linetta",
"version": "0.9.0",
"version": "0.9.1",
"identifier": "com.devlikebear.linetta",
"build": {
"frontendDist": "../dist",
Expand Down
54 changes: 18 additions & 36 deletions apps/desktop/src/components/FactBookPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,49 +21,31 @@
onImpactCheck?: (text: string) => void;
}

function buildReviewPrompt(sceneLabel: string): string {
return [
`현재 씬 "${sceneLabel}"에서 웹검색 팩트체크가 필요한 현실 주장 후보를 찾아줘.`,
"아직 web_search나 web_fetch를 실행하지 마.",
"후보가 있으면 설명은 짧게 하고 linetta-choices 블록 하나로만 보여줘.",
"각 options 항목은 반드시 `검색 후 자료집에 저장: <주장>` 형식으로 작성해.",
"작가가 후보를 선택하면 그때 web_search와 web_fetch로 출처 URL을 확인하고, create_fact_card로 자료집에 저장해.",
"web_fetch가 404/403/본문 부족이면 그 URL은 저장 후보에서 제외하고 대체 출처를 더 찾아.",
"web_search API 키가 없어 실패하면 포기하지 말고 작가에게 출처 URL 직접 입력을 요청해. 작가가 URL을 입력하면 web_fetch로 확인한 뒤 저장해.",
"출처 URL 없는 create_fact_card는 금지야. 후보가 없으면 짧게 이유만 말해.",
].join("\n");
// The fact-check flows send user-turn prompts to the companion, so they are
// localized like every other frontend string; the choice-prefix strip accepts
// all supported languages because a transcript can mix them.
type Translate = ReturnType<typeof useI18n>["t"];

function buildReviewPrompt(t: Translate, sceneLabel: string): string {
return t("factBook.ai.review", { sceneLabel, savePrefix: t("factBook.ai.savePrefix") });
}

const CHOICE_SAVE_PREFIXES = /^(?:검색 후 자료집에 저장|Save to dossier after search|検索して資料集に保存):\s*/i;

function claimFromChoice(text: string): string {
return text.replace(/^검색 후 자료집에 저장:\s*/i, "").trim();
return text.replace(CHOICE_SAVE_PREFIXES, "").trim();
}

function normalizeClaim(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}

function buildFactCheckPrompt(claim: string): string {
return [
`선택한 주장: ${claim}`,
"지금 바로 web_search로 신뢰 가능한 출처 후보를 찾고, 최소 1개 URL은 web_fetch로 본문 접근을 확인해.",
"web_fetch가 404/403/본문 부족이면 그 URL은 저장 후보에서 제외하고 같은 턴에 대체 출처를 더 검색해.",
"확인된 출처 URL이 있으면 같은 턴에 linetta_apply_ops의 create_fact_card로 자료집에 저장해.",
"create_fact_card 호출 없이 저장 완료라고 말하지 마. 저장이 실패하면 실패 이유와 필요한 다음 행동만 짧게 말해.",
"web_search나 web_fetch가 실패하면 포기하지 말고 출처 URL 직접 입력을 요청해.",
].join("\n");
function buildFactCheckPrompt(t: Translate, claim: string): string {
return t("factBook.ai.factCheck", { claim });
}

function buildAlternativeSourcePrompt(claim: string, failedURL: string, error: string): string {
return [
`선택한 주장: ${claim}`,
`방금 이 출처 URL은 앱의 저장 경로에서 실패했습니다: ${failedURL}`,
`실패 이유: ${error}`,
"이 URL은 저장 후보에서 제외해.",
"지금 바로 web_search로 신뢰 가능한 대체 출처 후보를 찾고, 최소 1개 URL은 web_fetch로 본문 접근을 확인해.",
"web_fetch가 404/403/본문 부족이면 그 URL도 저장 후보에서 제외하고 같은 턴에 대체 출처를 더 검색해.",
"확인된 출처 URL이 있으면 같은 턴에 linetta_apply_ops의 create_fact_card로 자료집에 저장해.",
"확인된 대체 출처가 없으면 저장했다고 말하지 말고, 출처 URL 직접 입력만 요청해.",
].join("\n");
function buildAlternativeSourcePrompt(t: Translate, claim: string, failedURL: string, error: string): string {
return t("factBook.ai.altSource", { claim, failedURL, error });
}

function firstURL(text: string): string {
Expand Down Expand Up @@ -260,7 +242,7 @@
setReviewing(true);
try {
await beforeReview?.();
await send(buildReviewPrompt(sceneLabel));
await send(buildReviewPrompt(t, sceneLabel));
} finally {
setReviewing(false);
}
Expand All @@ -278,7 +260,7 @@
markFeedbackStart();
void (async () => {
await beforeReview?.();
await send(buildFactCheckPrompt(claim));
await send(buildFactCheckPrompt(t, claim));
})();
}, [beforeReview, busy, directSaving, selectedClaimRequest, send]);

Expand Down Expand Up @@ -308,7 +290,7 @@
setSourceRetry(null);
setAwaitingFactSave(Boolean(claim));
markFeedbackStart();
void send(claim ? buildFactCheckPrompt(claim) : text);
void send(claim ? buildFactCheckPrompt(t, claim) : text);

Check failure on line 293 in apps/desktop/src/components/FactBookPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of the "void" operator.

See more on https://sonarcloud.io/project/issues?id=devlikebear_linetta&issues=AZ8xcJWK5RaNql5ZrYEe&open=AZ8xcJWK5RaNql5ZrYEe&pullRequest=24
};

const retryAlternativeSource = () => {
Expand All @@ -321,7 +303,7 @@
markFeedbackStart();
setFeedbackKind("ok");
setFeedbackNote(t("factBook.retryingSource"));
void send(buildAlternativeSourcePrompt(retry.claim, retry.url, retry.error));
void send(buildAlternativeSourcePrompt(t, retry.claim, retry.url, retry.error));

Check failure on line 306 in apps/desktop/src/components/FactBookPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of the "void" operator.

See more on https://sonarcloud.io/project/issues?id=devlikebear_linetta&issues=AZ8xcJWK5RaNql5ZrYEf&open=AZ8xcJWK5RaNql5ZrYEf&pullRequest=24
};

const focusReplyInput = () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/desktop/src/components/ai/AIContextChecklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,28 @@
selection: AIContextSelection;
onSelectionChange: (next: AIContextSelection) => void;
disabled?: boolean;
/** Which feature owns this checklist; picks feature-specific section labels. */
variant?: "ai" | "companion";
}

export function AIContextChecklistList({ preview, selection, onSelectionChange, disabled = false }: ListProps) {
type Translate = ReturnType<typeof useI18n>["t"];

/** Section labels are translated in the frontend by stable section id; the
* engine-provided label is only a fallback for unknown ids. */
export function contextSectionLabel(
t: Translate,
id: AIContextKey,
variant: "ai" | "companion",
fallback: string,
): string {
const key = id === "current_scene" && variant === "companion"
? "ai.context.section.current_scene.companion"
: `ai.context.section.${id}`;
const label = t(key);
return label === key ? fallback : label;
}

export function AIContextChecklistList({ preview, selection, onSelectionChange, disabled = false, variant = "ai" }: ListProps) {

Check warning on line 62 in apps/desktop/src/components/ai/AIContextChecklist.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=devlikebear_linetta&issues=AZ8xcJUy5RaNql5ZrYEd&open=AZ8xcJUy5RaNql5ZrYEd&pullRequest=24
const { t } = useI18n();
const [openId, setOpenId] = useState<AIContextKey | null>(null);

Expand All @@ -49,6 +68,7 @@
{preview.sections.map((section) => {
const checked = section.present && selection[section.id];
const canToggle = section.present && !disabled;
const label = contextSectionLabel(t, section.id, variant, section.label);
return (
<li key={section.id} className={section.present ? "" : "off"}>
<div className="ai-context-row">
Expand All @@ -60,7 +80,7 @@
disabled={!canToggle}
onChange={(e) => onSelectionChange({ ...selection, [section.id]: e.target.checked })}
/>
<span>{section.label}</span>
<span>{label}</span>
</label>
<span className="ai-context-meta">
{section.count > 0 && <span className="n">{formatCount(section, t)}</span>}
Expand All @@ -71,7 +91,7 @@
className="ai-preview-toggle"
onClick={() => setOpenId((id) => (id === section.id ? null : section.id))}
disabled={!section.present}
aria-label={t("ai.context.preview", { label: section.label })}
aria-label={t("ai.context.preview", { label })}
>
{openId === section.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/components/companion/CompanionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,7 @@ export function CompanionPanel({
selection={contextSelection}
onSelectionChange={setContextSelection}
disabled={isBusy}
variant="companion"
/>
)}
<div className="companion-reference-panel">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ interface Props {
type CandidateSelection = Record<string, Set<string>>;

export function ContextChangeWizard({ projectId, initialEntityId, initialText, autoCheck, onApplied }: Props) {
const { t } = useI18n();
const { language, t } = useI18n();
const [oldTerm, setOldTerm] = useState("");
const [newTerm, setNewTerm] = useState("");
const [entityMatches, setEntityMatches] = useState<Entity[]>([]);
Expand Down Expand Up @@ -74,7 +74,7 @@ export function ContextChangeWizard({ projectId, initialEntityId, initialText, a
if (!autoCheck || !term || autoCheckKey === term) return;
setAutoCheckKey(term);
setReport(null);
contextual.checkConsistency({ project_id: projectId, old_terms: [term] })
contextual.checkConsistency({ project_id: projectId, old_terms: [term], language })
.then((nextReport) => setReport(nextReport))
.catch((err) => setError(String(err)));
}, [autoCheck, autoCheckKey, initialText, projectId]);
Expand Down Expand Up @@ -146,6 +146,7 @@ export function ContextChangeWizard({ projectId, initialEntityId, initialText, a
contextual.planChange({
project_id: projectId,
type: "rename",
language,
...(selectedEntityId
? { entity_id: selectedEntityId }
: { selected_text: from, old_terms: [from] }),
Expand Down Expand Up @@ -184,6 +185,7 @@ export function ContextChangeWizard({ projectId, initialEntityId, initialText, a
old_terms: plan.old_terms,
new_terms: plan.new_terms,
changed_entity_ids: plan.target.entity_ids ?? [],
language,
});
setReport(nextReport);
})
Expand Down
23 changes: 17 additions & 6 deletions apps/desktop/src/hooks/useCompanion.events.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, renderHook as baseRenderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_CONTEXT_SELECTION } from "../components/ai/AIContextChecklist";

Expand All @@ -11,14 +12,21 @@ vi.mock("@tauri-apps/api/event", () => ({
},
}));

const rpc = vi.hoisted(() => ({ history: vi.fn(), send: vi.fn(), cancel: vi.fn(), clear: vi.fn(), compact: vi.fn() }));
const rpc = vi.hoisted(() => ({ history: vi.fn(), send: vi.fn(), cancel: vi.fn(), clear: vi.fn(), compact: vi.fn(), settingsGet: vi.fn() }));
vi.mock("../lib/rpc", () => ({
companion: { history: rpc.history, send: rpc.send, cancel: rpc.cancel, clear: rpc.clear, compact: rpc.compact },
settings: { get: rpc.settingsGet },
}));

import { stripProposalBlock } from "../lib/companionDisplay";
import { I18nProvider } from "../lib/i18n";
import { __resetCompanionSessionStoreForTests, classifyAISetupIssue, useCompanion } from "./useCompanion";

// useCompanion reads the app language via useI18n, so every hook render
// needs the provider. Shadow renderHook to inject it once.
const i18nWrapper = ({ children }: { children: ReactNode }) => <I18nProvider>{children}</I18nProvider>;
const renderHook: typeof baseRenderHook = (cb, options) => baseRenderHook(cb, { wrapper: i18nWrapper, ...options });

function fire(event: string, payload: unknown) {
const cb = ev.listeners.get(event);
if (!cb) throw new Error(`no listener registered for ${event}`);
Expand Down Expand Up @@ -59,6 +67,7 @@ describe("useCompanion streaming", () => {
__resetCompanionSessionStoreForTests();
vi.clearAllMocks();
ev.listeners.clear();
rpc.settingsGet.mockResolvedValue({ language: "ko" });
rpc.history.mockResolvedValue([]);
rpc.send.mockResolvedValue({ run_id: "r1" });
rpc.cancel.mockResolvedValue({ ok: true });
Expand Down Expand Up @@ -153,7 +162,7 @@ describe("useCompanion streaming", () => {
await result.current.send("본문은 빼고 봐줘");
});

expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "본문은 빼고 봐줘", { context: selection });
expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "본문은 빼고 봐줘", { context: selection, language: "ko" });
});

it("passes companion image attachments with the selected context", async () => {
Expand All @@ -171,7 +180,7 @@ describe("useCompanion streaming", () => {
await result.current.send("이미지 참고해줘", [image]);
});

expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "이미지 참고해줘", { context: selection, images: [image] });
expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "이미지 참고해줘", { context: selection, images: [image], language: "ko" });
});

it("loads persisted transcript separately for each scene scope", async () => {
Expand Down Expand Up @@ -225,7 +234,7 @@ describe("useCompanion streaming", () => {
});

expect(rpc.history).toHaveBeenCalledWith("p1", null, "project");
expect(rpc.send).toHaveBeenCalledWith("p1", "", "작품 전체 플롯 봐줘", { scope: "project" });
expect(rpc.send).toHaveBeenCalledWith("p1", "", "작품 전체 플롯 봐줘", { scope: "project", language: "ko" });
});

it("passes the selected outline structure to companion turns", async () => {
Expand All @@ -241,6 +250,7 @@ describe("useCompanion streaming", () => {
expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "아웃라인 작성해줘", {
context: selection,
outline_structure: outlineStructure,
language: "ko",
});
});

Expand Down Expand Up @@ -294,6 +304,7 @@ describe("useCompanion streaming", () => {

expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "현재 씬 본문 써줘", {
intent: { kind: "scene_write", target_node_id: "n1", apply_policy: "direct" },
language: "ko",
});
});

Expand Down Expand Up @@ -403,7 +414,7 @@ describe("useCompanion streaming", () => {
await result.current.compact();
});

expect(rpc.compact).toHaveBeenCalledWith("p1", "n1", "scene");
expect(rpc.compact).toHaveBeenCalledWith("p1", "n1", "scene", "ko");
expect(result.current.messages).toEqual([
{ role: "assistant", content: "이전 컴패니언 대화 요약\n- 나: 긴 질문" },
]);
Expand Down
Loading
Loading