diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ecc83..6943e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a2ace95..6855474 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "linetta-desktop", - "version": "0.9.0", + "version": "0.9.1", "license": "AGPL-3.0-only", "private": true, "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 99e1c99..05793c6 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1938,7 +1938,7 @@ dependencies = [ [[package]] name = "linetta-desktop" -version = "0.9.0" +version = "0.9.1" dependencies = [ "anyhow", "cc", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index fb99dd7..ed2a3d7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 2b74a97..b8cd9f3 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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", diff --git a/apps/desktop/src/components/FactBookPanel.tsx b/apps/desktop/src/components/FactBookPanel.tsx index 6e65a00..774185f 100644 --- a/apps/desktop/src/components/FactBookPanel.tsx +++ b/apps/desktop/src/components/FactBookPanel.tsx @@ -21,49 +21,31 @@ interface Props { 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["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 { @@ -260,7 +242,7 @@ export function FactBookPanel({ projectId, nodeId, sceneLabel, selectedClaimRequ setReviewing(true); try { await beforeReview?.(); - await send(buildReviewPrompt(sceneLabel)); + await send(buildReviewPrompt(t, sceneLabel)); } finally { setReviewing(false); } @@ -278,7 +260,7 @@ export function FactBookPanel({ projectId, nodeId, sceneLabel, selectedClaimRequ markFeedbackStart(); void (async () => { await beforeReview?.(); - await send(buildFactCheckPrompt(claim)); + await send(buildFactCheckPrompt(t, claim)); })(); }, [beforeReview, busy, directSaving, selectedClaimRequest, send]); @@ -308,7 +290,7 @@ export function FactBookPanel({ projectId, nodeId, sceneLabel, selectedClaimRequ setSourceRetry(null); setAwaitingFactSave(Boolean(claim)); markFeedbackStart(); - void send(claim ? buildFactCheckPrompt(claim) : text); + void send(claim ? buildFactCheckPrompt(t, claim) : text); }; const retryAlternativeSource = () => { @@ -321,7 +303,7 @@ export function FactBookPanel({ projectId, nodeId, sceneLabel, selectedClaimRequ 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)); }; const focusReplyInput = () => { diff --git a/apps/desktop/src/components/ai/AIContextChecklist.tsx b/apps/desktop/src/components/ai/AIContextChecklist.tsx index a261466..43bdd42 100644 --- a/apps/desktop/src/components/ai/AIContextChecklist.tsx +++ b/apps/desktop/src/components/ai/AIContextChecklist.tsx @@ -38,9 +38,28 @@ interface ListProps { 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["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) { const { t } = useI18n(); const [openId, setOpenId] = useState(null); @@ -49,6 +68,7 @@ export function AIContextChecklistList({ preview, selection, onSelectionChange, {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 (
  • @@ -60,7 +80,7 @@ export function AIContextChecklistList({ preview, selection, onSelectionChange, disabled={!canToggle} onChange={(e) => onSelectionChange({ ...selection, [section.id]: e.target.checked })} /> - {section.label} + {label} {section.count > 0 && {formatCount(section, t)}} @@ -71,7 +91,7 @@ export function AIContextChecklistList({ preview, selection, onSelectionChange, 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 ? : } diff --git a/apps/desktop/src/components/companion/CompanionPanel.tsx b/apps/desktop/src/components/companion/CompanionPanel.tsx index ed5a6fe..dbdbe6e 100644 --- a/apps/desktop/src/components/companion/CompanionPanel.tsx +++ b/apps/desktop/src/components/companion/CompanionPanel.tsx @@ -1368,6 +1368,7 @@ export function CompanionPanel({ selection={contextSelection} onSelectionChange={setContextSelection} disabled={isBusy} + variant="companion" /> )}
    diff --git a/apps/desktop/src/components/contextual/ContextChangeWizard.tsx b/apps/desktop/src/components/contextual/ContextChangeWizard.tsx index 749ae6d..7072b99 100644 --- a/apps/desktop/src/components/contextual/ContextChangeWizard.tsx +++ b/apps/desktop/src/components/contextual/ContextChangeWizard.tsx @@ -25,7 +25,7 @@ interface Props { type CandidateSelection = Record>; 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([]); @@ -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]); @@ -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] }), @@ -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); }) diff --git a/apps/desktop/src/hooks/useCompanion.events.test.tsx b/apps/desktop/src/hooks/useCompanion.events.test.tsx index afe5032..bc70d21 100644 --- a/apps/desktop/src/hooks/useCompanion.events.test.tsx +++ b/apps/desktop/src/hooks/useCompanion.events.test.tsx @@ -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"; @@ -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 }) => {children}; +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}`); @@ -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 }); @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -241,6 +250,7 @@ describe("useCompanion streaming", () => { expect(rpc.send).toHaveBeenCalledWith("p1", "n1", "아웃라인 작성해줘", { context: selection, outline_structure: outlineStructure, + language: "ko", }); }); @@ -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", }); }); @@ -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- 나: 긴 질문" }, ]); diff --git a/apps/desktop/src/hooks/useCompanion.ts b/apps/desktop/src/hooks/useCompanion.ts index 03c84b2..897eb19 100644 --- a/apps/desktop/src/hooks/useCompanion.ts +++ b/apps/desktop/src/hooks/useCompanion.ts @@ -1,6 +1,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import { companion as companionApi } from "../lib/rpc"; +import { useI18n } from "../lib/i18n"; import { extractApplyOpsProposal, stripProposalBlock } from "../lib/companionDisplay"; import type { CompanionMessage, CompanionProposal, CompanionChoices, @@ -445,6 +446,7 @@ export function useCompanion( outlineStructure?: string, historyScope: CompanionHistoryScope = "scene", ) { + const { language } = useI18n(); const currentNodeId = currentNodeIdFrom(nodeTarget); const effectiveScope: CompanionHistoryScope = historyScope === "scene" && currentNodeId ? "scene" : "project"; const storeKey = companionStoreKey(projectId, effectiveScope, currentNodeId); @@ -488,18 +490,16 @@ export function useCompanion( pendingChoices: null, })); try { - const payload = contextSelection || outlineStructure || images.length > 0 || intent || effectiveScope === "project" - ? { - ...(contextSelection ? { context: contextSelection } : {}), - ...(outlineStructure ? { outline_structure: outlineStructure } : {}), - ...(images.length > 0 ? { images } : {}), - ...(intent ? { intent } : {}), - ...(effectiveScope === "project" ? { scope: "project" as const } : {}), - } - : undefined; - const { run_id } = payload - ? await companionApi.send(projectId, effectiveScope === "scene" ? currentNodeId ?? "" : "", trimmed, payload) - : await companionApi.send(projectId, currentNodeId ?? "", trimmed); + const payload = { + ...(contextSelection ? { context: contextSelection } : {}), + ...(outlineStructure ? { outline_structure: outlineStructure } : {}), + ...(images.length > 0 ? { images } : {}), + ...(intent ? { intent } : {}), + ...(effectiveScope === "project" ? { scope: "project" as const } : {}), + language, + }; + const nodeArg = effectiveScope === "scene" ? currentNodeId ?? "" : ""; + const { run_id } = await companionApi.send(projectId, nodeArg, trimmed, payload); updateStore(storeKey, (state) => { if (state.runId !== PENDING_RUN_ID) return state; runStores.set(run_id, storeKey); @@ -514,7 +514,7 @@ export function useCompanion( sending: false, })); } - }, [contextSelection, currentNodeId, effectiveScope, outlineStructure, projectId, storeKey]); + }, [contextSelection, currentNodeId, effectiveScope, language, outlineStructure, projectId, storeKey]); const cancel = useCallback(() => { const id = getStore(storeKey).state.runId; @@ -529,7 +529,7 @@ export function useCompanion( }, [currentNodeId, effectiveScope, projectId, storeKey]); const compact = useCallback(async () => { - const msgs = await companionApi.compact(projectId, effectiveScope === "scene" ? currentNodeId : null, effectiveScope); + const msgs = await companionApi.compact(projectId, effectiveScope === "scene" ? currentNodeId : null, effectiveScope, language); const store = getStore(storeKey); store.historyLoaded = true; updateStore(storeKey, (state) => ({ @@ -544,7 +544,7 @@ export function useCompanion( pendingProposal: null, pendingChoices: null, })); - }, [currentNodeId, effectiveScope, projectId, storeKey]); + }, [currentNodeId, effectiveScope, language, projectId, storeKey]); return { messages: snapshot.messages, diff --git a/apps/desktop/src/lib/i18n.displayNodeLabel.test.ts b/apps/desktop/src/lib/i18n.displayNodeLabel.test.ts new file mode 100644 index 0000000..9d59785 --- /dev/null +++ b/apps/desktop/src/lib/i18n.displayNodeLabel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { displayNodeLabel } from "./i18n"; + +describe("displayNodeLabel", () => { + it("translates web-novel part/chapter labels to English", () => { + expect(displayNodeLabel("en", "1권")).toBe("Arc 1"); + expect(displayNodeLabel("en", "1화")).toBe("Episode 1"); + }); + + it("translates web-novel part/chapter labels to Japanese", () => { + expect(displayNodeLabel("ja", "1권")).toBe("第1巻"); + expect(displayNodeLabel("ja", "1화")).toBe("第1話"); + }); + + it("keeps Korean web-novel labels under Korean", () => { + expect(displayNodeLabel("ko", "2권")).toBe("2권"); + expect(displayNodeLabel("ko", "3화")).toBe("3화"); + }); + + it("still handles scene and chapter labels", () => { + expect(displayNodeLabel("en", "씬 1")).toBe("Scene 1"); + expect(displayNodeLabel("en", "1장")).toBe("Chapter 1"); + }); + + it("translates novel part labels", () => { + expect(displayNodeLabel("en", "1부")).toBe("Part 1"); + expect(displayNodeLabel("ja", "2부")).toBe("第2部"); + expect(displayNodeLabel("ko", "1부")).toBe("1부"); + }); + + it("passes through unknown labels unchanged", () => { + expect(displayNodeLabel("en", "Prologue")).toBe("Prologue"); + }); +}); diff --git a/apps/desktop/src/lib/i18n.tsx b/apps/desktop/src/lib/i18n.tsx index 801467b..ca0868c 100644 --- a/apps/desktop/src/lib/i18n.tsx +++ b/apps/desktop/src/lib/i18n.tsx @@ -775,6 +775,10 @@ const messages: Record = { "factBook.reviewing": "검토 중…", "factBook.reviewHint": "현재 씬에서 현실 자료 확인이 필요한 후보를 먼저 고르고, 선택한 항목만 웹검색으로 저장합니다.", "factBook.applied": "자료집을 새로고침했습니다.", + "factBook.ai.savePrefix": "검색 후 자료집에 저장", + "factBook.ai.review": "현재 씬 \"{sceneLabel}\"에서 웹검색 팩트체크가 필요한 현실 주장 후보를 찾아줘.\n아직 web_search나 web_fetch를 실행하지 마.\n후보가 있으면 설명은 짧게 하고 linetta-choices 블록 하나로만 보여줘.\n각 options 항목은 반드시 `{savePrefix}: <주장>` 형식으로 작성해.\n작가가 후보를 선택하면 그때 web_search와 web_fetch로 출처 URL을 확인하고, create_fact_card로 자료집에 저장해.\nweb_fetch가 404/403/본문 부족이면 그 URL은 저장 후보에서 제외하고 대체 출처를 더 찾아.\nweb_search API 키가 없어 실패하면 포기하지 말고 작가에게 출처 URL 직접 입력을 요청해. 작가가 URL을 입력하면 web_fetch로 확인한 뒤 저장해.\n출처 URL 없는 create_fact_card는 금지야. 후보가 없으면 짧게 이유만 말해.", + "factBook.ai.factCheck": "선택한 주장: {claim}\n지금 바로 web_search로 신뢰 가능한 출처 후보를 찾고, 최소 1개 URL은 web_fetch로 본문 접근을 확인해.\nweb_fetch가 404/403/본문 부족이면 그 URL은 저장 후보에서 제외하고 같은 턴에 대체 출처를 더 검색해.\n확인된 출처 URL이 있으면 같은 턴에 linetta_apply_ops의 create_fact_card로 자료집에 저장해.\ncreate_fact_card 호출 없이 저장 완료라고 말하지 마. 저장이 실패하면 실패 이유와 필요한 다음 행동만 짧게 말해.\nweb_search나 web_fetch가 실패하면 포기하지 말고 출처 URL 직접 입력을 요청해.", + "factBook.ai.altSource": "선택한 주장: {claim}\n방금 이 출처 URL은 앱의 저장 경로에서 실패했습니다: {failedURL}\n실패 이유: {error}\n이 URL은 저장 후보에서 제외해.\n지금 바로 web_search로 신뢰 가능한 대체 출처 후보를 찾고, 최소 1개 URL은 web_fetch로 본문 접근을 확인해.\nweb_fetch가 404/403/본문 부족이면 그 URL도 저장 후보에서 제외하고 같은 턴에 대체 출처를 더 검색해.\n확인된 출처 URL이 있으면 같은 턴에 linetta_apply_ops의 create_fact_card로 자료집에 저장해.\n확인된 대체 출처가 없으면 저장했다고 말하지 말고, 출처 URL 직접 입력만 요청해.", "factBook.autoSaving": "응답에서 출처 URL을 찾아 자료집에 저장하는 중입니다.", "factBook.saveNotApplied": "아직 자료집 저장 이벤트가 확인되지 않았습니다. 출처 URL을 입력하면 바로 저장할 수 있습니다.", "factBook.directSaved": "출처 URL을 확인하고 자료집에 저장했습니다.", @@ -825,6 +829,21 @@ const messages: Record = { "ai.context.preview": "{label} 미리보기", "ai.context.empty": "전달할 내용이 없습니다.", "ai.context.count": "{count}개", + "ai.context.section.current_scene": "현재 씬 본문", + "ai.context.section.current_scene.companion": "작성된 본문 발췌", + "ai.context.section.overview": "작품 개요", + "ai.context.section.synopsis": "작품 시놉시스", + "ai.context.section.nearby_scenes": "직전·직후 씬 발췌", + "ai.context.section.related_scenes": "관련 과거 씬 (멘션 RAG)", + "ai.context.section.plot": "플롯 (스토리라인&비트)", + "ai.context.section.entities": "세계관 요소", + "ai.context.section.relationships": "관계", + "ai.context.section.notes": "작가 주석", + "ai.context.section.project_meta": "작품 설정 (장르/분량/시점)", + "ai.context.section.style_notes": "작가 style notes", + "ai.context.section.facts": "팩트 자료집", + "ai.context.section.memories": "컴패니언 기억", + "ai.context.section.references": "추가 레퍼런스", "engine.checking": "엔진 상태를 확인하는 중...", "engine.failedTitle": "엔진을 시작하지 못했습니다", "engine.failedDescription": "Linetta의 내장 Go 엔진이 응답하지 않습니다. 앱을 다시 시도하거나 아래 진단 정보를 확인하세요.", @@ -1772,6 +1791,10 @@ const messages: Record = { "factBook.reviewing": "Reviewing…", "factBook.reviewHint": "Pick claims that need real-world checking first; only the selected claim is searched and saved.", "factBook.applied": "Fact Book refreshed.", + "factBook.ai.savePrefix": "Save to dossier after search", + "factBook.ai.review": "Find real-world claims in the current scene \"{sceneLabel}\" that would benefit from a web-search fact check.\nDo not run web_search or web_fetch yet.\nIf there are candidates, keep the explanation short and present them in exactly one linetta-choices block.\nEvery options item must be written as `{savePrefix}: `.\nWhen the writer picks a candidate, verify source URLs with web_search and web_fetch, then save it to the dossier with create_fact_card.\nIf web_fetch returns 404/403 or too little text, drop that URL as a source candidate and find alternatives.\nIf web_search fails because no API key is set, do not give up — ask the writer to paste a source URL directly; verify it with web_fetch before saving.\ncreate_fact_card without a source URL is forbidden. If there are no candidates, briefly say why.", + "factBook.ai.factCheck": "Selected claim: {claim}\nRight now, use web_search to find trustworthy source candidates and verify body access for at least one URL with web_fetch.\nIf web_fetch returns 404/403 or too little text, drop that URL and search for alternatives in the same turn.\nOnce a verified source URL exists, save it to the dossier with linetta_apply_ops create_fact_card in the same turn.\nNever claim it is saved without calling create_fact_card. If saving fails, state only the failure reason and the next step, briefly.\nIf web_search or web_fetch fails, do not give up — ask for a source URL to be pasted directly.", + "factBook.ai.altSource": "Selected claim: {claim}\nThis source URL just failed in the app's save path: {failedURL}\nFailure reason: {error}\nExclude this URL from the save candidates.\nRight now, use web_search to find trustworthy alternative sources and verify body access for at least one URL with web_fetch.\nIf web_fetch returns 404/403 or too little text, drop that URL too and search for more alternatives in the same turn.\nOnce a verified source URL exists, save it to the dossier with linetta_apply_ops create_fact_card in the same turn.\nIf no verified alternative exists, do not claim it was saved — only ask for a source URL to be pasted directly.", "factBook.autoSaving": "Saving the source URL found in the response to the Fact Book.", "factBook.saveNotApplied": "No Fact Book save event was confirmed yet. Enter a source URL to save it directly.", "factBook.directSaved": "Source URL checked and saved to the Fact Book.", @@ -1822,6 +1845,21 @@ const messages: Record = { "ai.context.preview": "{label} preview", "ai.context.empty": "No content to send.", "ai.context.count": "{count}", + "ai.context.section.current_scene": "Current scene text", + "ai.context.section.current_scene.companion": "Written manuscript excerpts", + "ai.context.section.overview": "Work overview", + "ai.context.section.synopsis": "Synopsis", + "ai.context.section.nearby_scenes": "Adjacent scene excerpts", + "ai.context.section.related_scenes": "Related past scenes (mention RAG)", + "ai.context.section.plot": "Plot (storylines & beats)", + "ai.context.section.entities": "World elements", + "ai.context.section.relationships": "Relationships", + "ai.context.section.notes": "Author notes", + "ai.context.section.project_meta": "Project settings (genre/length/POV)", + "ai.context.section.style_notes": "Author style notes", + "ai.context.section.facts": "Fact dossier", + "ai.context.section.memories": "Companion memories", + "ai.context.section.references": "Additional references", "engine.checking": "Checking engine status...", "engine.failedTitle": "Could not start the engine", "engine.failedDescription": "Linetta's embedded Go engine is not responding. Try again or check the diagnostics below.", @@ -2769,6 +2807,10 @@ const messages: Record = { "factBook.reviewing": "確認中…", "factBook.reviewHint": "現実資料の確認が必要な候補を先に選び、選んだ項目だけをWeb検索して保存します。", "factBook.applied": "資料集を更新しました。", + "factBook.ai.savePrefix": "検索して資料集に保存", + "factBook.ai.review": "現在のシーン「{sceneLabel}」から、ウェブ検索でのファクトチェックが必要な現実の主張候補を見つけて。\nまだ web_search や web_fetch は実行しないで。\n候補があれば説明は短くし、linetta-choices ブロック一つだけで提示して。\n各 options 項目は必ず `{savePrefix}: <主張>` の形式で書いて。\n作家が候補を選んだら、そのとき web_search と web_fetch で出典 URL を確認し、create_fact_card で資料集に保存して。\nweb_fetch が 404/403/本文不足の場合、その URL は保存候補から除外し、代替出典をさらに探して。\nweb_search が API キー不足で失敗しても諦めず、作家に出典 URL の直接入力を依頼して。作家が URL を入力したら web_fetch で確認してから保存して。\n出典 URL の無い create_fact_card は禁止。候補が無ければ理由だけ短く伝えて。", + "factBook.ai.factCheck": "選択した主張: {claim}\n今すぐ web_search で信頼できる出典候補を探し、少なくとも1つの URL は web_fetch で本文アクセスを確認して。\nweb_fetch が 404/403/本文不足の場合、その URL は保存候補から除外し、同じターンで代替出典をさらに検索して。\n確認済みの出典 URL があれば、同じターンで linetta_apply_ops の create_fact_card で資料集に保存して。\ncreate_fact_card を呼ばずに保存完了と言わないで。保存に失敗したら失敗理由と必要な次の行動だけ短く伝えて。\nweb_search や web_fetch が失敗しても諦めず、出典 URL の直接入力を依頼して。", + "factBook.ai.altSource": "選択した主張: {claim}\nこの出典 URL はアプリの保存経路で失敗しました: {failedURL}\n失敗理由: {error}\nこの URL は保存候補から除外して。\n今すぐ web_search で信頼できる代替出典候補を探し、少なくとも1つの URL は web_fetch で本文アクセスを確認して。\nweb_fetch が 404/403/本文不足の場合、その URL も保存候補から除外し、同じターンで代替出典をさらに検索して。\n確認済みの出典 URL があれば、同じターンで linetta_apply_ops の create_fact_card で資料集に保存して。\n確認済みの代替出典が無ければ保存したと言わず、出典 URL の直接入力だけ依頼して。", "factBook.autoSaving": "応答内の出典URLを資料集に保存しています。", "factBook.saveNotApplied": "資料集への保存イベントはまだ確認できません。出典URLを入力すると直接保存できます。", "factBook.directSaved": "出典URLを確認し、資料集に保存しました。", @@ -2819,6 +2861,21 @@ const messages: Record = { "ai.context.preview": "{label} プレビュー", "ai.context.empty": "送信する内容がありません。", "ai.context.count": "{count}件", + "ai.context.section.current_scene": "現在のシーン本文", + "ai.context.section.current_scene.companion": "執筆済み本文の抜粋", + "ai.context.section.overview": "作品概要", + "ai.context.section.synopsis": "作品シノプシス", + "ai.context.section.nearby_scenes": "前後シーンの抜粋", + "ai.context.section.related_scenes": "関連する過去シーン(メンションRAG)", + "ai.context.section.plot": "プロット(ストーリーライン&ビート)", + "ai.context.section.entities": "世界観要素", + "ai.context.section.relationships": "関係", + "ai.context.section.notes": "作家メモ", + "ai.context.section.project_meta": "作品設定(ジャンル/分量/視点)", + "ai.context.section.style_notes": "作家スタイルノート", + "ai.context.section.facts": "ファクト資料集", + "ai.context.section.memories": "コンパニオンの記憶", + "ai.context.section.references": "追加リファレンス", "engine.checking": "エンジン状態を確認中...", "engine.failedTitle": "エンジンを開始できませんでした", "engine.failedDescription": "Linetta の内蔵 Go エンジンが応答していません。再試行するか、下の診断情報を確認してください。", @@ -3345,6 +3402,24 @@ export function displayNodeLabel(language: AppLanguage, label: string): string { number: chapter[1] ?? chapter[2] ?? chapter[3] ?? "", }); } + const part = trimmed.match(/^(?:(\d+)부|Part\s+(\d+)|第(\d+)部)$/i); + if (part) { + return translate(language, "workspace.partNumber", { + number: part[1] ?? part[2] ?? part[3] ?? "", + }); + } + const webNovelPart = trimmed.match(/^(?:(\d+)권|Arc\s+(\d+)|第(\d+)巻)$/i); + if (webNovelPart) { + return translate(language, "workspace.webNovelPartNumber", { + number: webNovelPart[1] ?? webNovelPart[2] ?? webNovelPart[3] ?? "", + }); + } + const webNovelChapter = trimmed.match(/^(?:(\d+)화|Episode\s+(\d+)|第(\d+)話)$/i); + if (webNovelChapter) { + return translate(language, "workspace.webNovelChapterNumber", { + number: webNovelChapter[1] ?? webNovelChapter[2] ?? webNovelChapter[3] ?? "", + }); + } return label; } diff --git a/apps/desktop/src/lib/rpc.ts b/apps/desktop/src/lib/rpc.ts index fddad95..05e8fa5 100644 --- a/apps/desktop/src/lib/rpc.ts +++ b/apps/desktop/src/lib/rpc.ts @@ -377,12 +377,12 @@ export const plot = { }; export const companion = { - send: (projectId: string, nodeId: string, text: string, options?: Pick & { images?: CompanionImageAttachment[]; intent?: CompanionIntent; scope?: CompanionHistoryScope }) => + send: (projectId: string, nodeId: string, text: string, options?: Pick & { images?: CompanionImageAttachment[]; intent?: CompanionIntent; scope?: CompanionHistoryScope; language?: string }) => rpcCall<{ run_id: string }>("companion.send", { project_id: projectId, node_id: nodeId, text, - options: options ? { context: options.context, outline_structure: options.outline_structure, intent: options.intent, scope: options.scope } : {}, + options: options ? { context: options.context, outline_structure: options.outline_structure, intent: options.intent, scope: options.scope, language: options.language } : {}, images: options?.images ?? [], }), previewContext: (projectId: string, nodeId: string, options?: Pick): Promise => @@ -398,11 +398,12 @@ export const companion = { ...(limit ? { limit } : {}), }) .then((r) => r.messages ?? []), - compact: (projectId: string, nodeId?: string | null, scope?: CompanionHistoryScope) => + compact: (projectId: string, nodeId?: string | null, scope?: CompanionHistoryScope, language?: string) => rpcCall<{ messages: CompanionMessage[] }>("companion.compact", { project_id: projectId, ...(nodeId ? { node_id: nodeId } : {}), ...(scope ? { scope } : {}), + ...(language ? { language } : {}), }) .then((r) => r.messages ?? []), clear: (projectId: string, nodeId?: string | null, scope?: CompanionHistoryScope) => diff --git a/apps/desktop/src/lib/types.ts b/apps/desktop/src/lib/types.ts index 9697fd2..17e3653 100644 --- a/apps/desktop/src/lib/types.ts +++ b/apps/desktop/src/lib/types.ts @@ -139,6 +139,7 @@ export interface ContextChangeInput extends ResolveTargetInput { old_terms?: string[]; new_terms?: string[]; review_only?: boolean; + language?: string; } export interface MetadataCandidate { @@ -189,6 +190,7 @@ export interface ConsistencyInput { old_terms: string[]; new_terms?: string[]; changed_entity_ids?: string[]; + language?: string; } export interface ConsistencyIssue { @@ -295,6 +297,8 @@ export interface AIOptions { short_form: boolean; context?: AIContextSelection; outline_structure?: string; + /** App UI language; the engine switches AI prompts to English for "en". */ + language?: string; } export type CompanionIntentKind = diff --git a/apps/desktop/src/routes/Workspace.tsx b/apps/desktop/src/routes/Workspace.tsx index b34fe9a..6ba21bf 100644 --- a/apps/desktop/src/routes/Workspace.tsx +++ b/apps/desktop/src/routes/Workspace.tsx @@ -1858,7 +1858,9 @@ export function Workspace() { const args = { nodeId: load.node.id, prompt: promptText, - options: aiOptions, + // Inject the current UI language at send time so the engine + // builds prompts (and the AI responds) in that language. + options: { ...aiOptions, language }, selectionText, }; if (variationsOn) gen.startVariations(args, 3); diff --git a/engine/internal/ai/ai.go b/engine/internal/ai/ai.go index c5c7453..ef8e2b8 100644 --- a/engine/internal/ai/ai.go +++ b/engine/internal/ai/ai.go @@ -22,6 +22,7 @@ type Options struct { ShortForm bool `json:"short_form"` // ask for one-paragraph length Context ContextSelection `json:"context,omitempty"` OutlineStructure string `json:"outline_structure,omitempty"` + Language string `json:"language,omitempty"` // app UI language; "en*" switches prompts to English (Korean default) } // ContextKey identifies one independently toggleable context section. diff --git a/engine/internal/ai/context.go b/engine/internal/ai/context.go index dd8fa25..5425e1c 100644 --- a/engine/internal/ai/context.go +++ b/engine/internal/ai/context.go @@ -687,7 +687,7 @@ func PreviewFromContext(c Context, selection ContextSelection) ContextPreview { add(ContextKeyEntities, "세계관 요소", len(c.Entities), renderEntitiesPreview(c.Entities), false) add(ContextKeyRelationships, "관계", len(c.Relationships), renderRelationshipsPreview(c.Relationships), false) add(ContextKeyNotes, "작가 주석", len(c.Notes), renderNotesPreview(c.Notes), false) - if meta := renderProjectMeta(c.Project); meta != "" { + if meta := renderProjectMeta(c.Project, ""); meta != "" { add(ContextKeyProjectMeta, "작품 설정 (장르/분량/시점)", countProjectMeta(c.Project), meta, false) } else { add(ContextKeyProjectMeta, "작품 설정 (장르/분량/시점)", 0, "", false) @@ -794,7 +794,7 @@ func renderPlotPreview(spine plot.Spine) string { func renderEntitiesPreview(entities []EntityBrief) string { var b strings.Builder for _, e := range entities { - b.WriteString(fmt.Sprintf("- @%s — %s", e.Name, kindLabel(e.Kind))) + b.WriteString(fmt.Sprintf("- @%s — %s", e.Name, kindLabel(e.Kind, ""))) if e.Role != "" { b.WriteString(" / " + e.Role) } diff --git a/engine/internal/ai/prompts.go b/engine/internal/ai/prompts.go index 247ddd1..b8f0940 100644 --- a/engine/internal/ai/prompts.go +++ b/engine/internal/ai/prompts.go @@ -4,10 +4,32 @@ import ( "fmt" "strings" + "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/tars/pkg/llm" ) +// langIsEnglish reports whether the app UI language selects English prompts. +func langIsEnglish(lang string) bool { + return strings.HasPrefix(lang, "en") +} + +// langIsJapanese reports whether the app UI language selects Japanese prompts. +func langIsJapanese(lang string) bool { + return strings.HasPrefix(lang, "ja") +} + +// langPick returns the string for the app language; Korean is the default. +func langPick(lang, ko, en, ja string) string { + if langIsEnglish(lang) { + return en + } + if langIsJapanese(lang) { + return ja + } + return ko +} + // PresetID identifies a built-in prompt template. type PresetID string @@ -53,6 +75,67 @@ func BuildMessages(c Context) []llm.ChatMessage { } func buildSystem(c Context) string { + lang := c.Options.Language + if langIsJapanese(lang) { + var b strings.Builder + b.WriteString("あなたは小説家のインラインエディタです。") + b.WriteString("作家の依頼した作業を本文の流れに合わせて行ってください。") + b.WriteString("日本語で答えてください。出力はマークダウンの見出しなしで、純粋な本文のみ書いてください。\n\n") + switch c.Options.Tone { + case TonePresetMy: + if strings.TrimSpace(c.StyleNotes) != "" { + b.WriteString("作家のスタイルノート(必ず従うこと):\n") + b.WriteString(c.StyleNotes) + b.WriteString("\n\n") + } + case TonePresetCool: + b.WriteString("今回の出力は冷たく距離感のあるトーンを保ってください。\n\n") + case TonePresetSensory: + b.WriteString("今回の出力は視覚・聴覚・触覚の描写を積極的に使う感覚的なトーンを保ってください。\n\n") + case TonePresetDry: + b.WriteString("今回の出力は形容詞を減らし、事実中心の乾いたトーンを保ってください。\n\n") + case TonePresetTense: + b.WriteString("今回の出力は短い文と切れ味で緊張感を生かしたトーンを保ってください。\n\n") + case TonePresetLyrical: + b.WriteString("今回の出力はリズムの生きた叙情的なトーンを保ってください。\n\n") + case TonePresetHumor: + b.WriteString("今回の出力は軽くウィットのあるトーンを保ってください。\n\n") + } + if c.Options.ShortForm { + b.WriteString("出力は一段落以内、500字以内で短く書いてください。\n") + } + return b.String() + } + if langIsEnglish(lang) { + var b strings.Builder + b.WriteString("You are an inline editor for a fiction writer. ") + b.WriteString("Carry out the writer's request so it fits the flow of the manuscript. ") + b.WriteString("Respond in English. Output pure prose only, without markdown headers.\n\n") + switch c.Options.Tone { + case TonePresetMy: + if strings.TrimSpace(c.StyleNotes) != "" { + b.WriteString("The writer's style notes (must follow):\n") + b.WriteString(c.StyleNotes) + b.WriteString("\n\n") + } + case TonePresetCool: + b.WriteString("Keep this output in a cold, detached tone.\n\n") + case TonePresetSensory: + b.WriteString("Keep this output in a sensory tone, rich in sight, sound, and touch.\n\n") + case TonePresetDry: + b.WriteString("Keep this output dry and factual, trimming adjectives.\n\n") + case TonePresetTense: + b.WriteString("Keep this output tense, using short, clipped sentences.\n\n") + case TonePresetLyrical: + b.WriteString("Keep this output lyrical, with a living cadence.\n\n") + case TonePresetHumor: + b.WriteString("Keep this output light and witty.\n\n") + } + if c.Options.ShortForm { + b.WriteString("Keep the output short: one paragraph, at most 500 characters.\n") + } + return b.String() + } var b strings.Builder b.WriteString("당신은 한국어 소설 작가의 인라인 편집기입니다. ") b.WriteString("작가가 요청한 작업을 본문 흐름에 맞게 수행하세요. ") @@ -85,64 +168,65 @@ func buildSystem(c Context) string { func buildUser(c Context) string { var b strings.Builder + lang := c.Options.Language capPlotDescriptions(&c.Plot, plotMaxChars) // Plan 18 project meta: a one-line summary of the user-configured genres, // length target, and default POV. Sits above everything else so the model // frames the entire context within the writer's stated intent. - if meta := renderProjectMeta(c.Project); meta != "" { - b.WriteString("## 작품 설정\n") + if meta := renderProjectMeta(c.Project, lang); meta != "" { + b.WriteString(langPick(lang, "## 작품 설정\n", "## Project Settings\n", "## 作品設定\n")) b.WriteString(meta) b.WriteString("\n\n") } overview := strings.TrimSpace(c.Outline) if overview != "" { - b.WriteString("## 작품 개요\n") + b.WriteString(langPick(lang, "## 작품 개요\n", "## Work Overview\n", "## 作品概要\n")) b.WriteString(overview) b.WriteString("\n\n") } synopsis := strings.TrimSpace(c.Project.Synopsis) if synopsis != "" { - b.WriteString("## 작품 시놉시스\n") + b.WriteString(langPick(lang, "## 작품 시놉시스\n", "## Synopsis\n", "## 作品シノプシス\n")) b.WriteString(synopsis) b.WriteString("\n\n") } if len(c.Hierarchical.NearbyLeafSummaries) > 0 { - b.WriteString("## 직전·직후 씬 발췌\n") + b.WriteString(langPick(lang, "## 직전·직후 씬 발췌\n", "## Adjacent Scene Excerpts\n", "## 前後シーンの抜粋\n")) for _, ss := range c.Hierarchical.NearbyLeafSummaries { - b.WriteString(fmt.Sprintf("- [%s] %s\n", ss.Label, ss.Body)) + b.WriteString(fmt.Sprintf("- [%s] %s\n", node.DisplayBreadcrumb(ss.Label, lang), ss.Body)) } b.WriteString("\n") } if len(c.RelatedScenes) > 0 { - b.WriteString("## 관련 과거 씬\n") + b.WriteString(langPick(lang, "## 관련 과거 씬\n", "## Related Past Scenes\n", "## 関連する過去シーン\n")) for _, ss := range c.RelatedScenes { - b.WriteString(fmt.Sprintf("- [%s] %s\n", ss.Label, ss.Body)) + b.WriteString(fmt.Sprintf("- [%s] %s\n", node.DisplayBreadcrumb(ss.Label, lang), ss.Body)) } b.WriteString("\n") } if strings.TrimSpace(c.SceneText) != "" { - b.WriteString(fmt.Sprintf("## 현재 씬: %s\n", c.SceneLabel)) + b.WriteString(fmt.Sprintf(langPick(lang, "## 현재 씬: %s\n", "## Current Scene: %s\n", "## 現在のシーン: %s\n"), node.DisplayBreadcrumb(c.SceneLabel, lang))) b.WriteString(c.SceneText) b.WriteString("\n\n") } if strings.TrimSpace(c.SelectionText) != "" { - b.WriteString("## 선택 영역\n") + b.WriteString(langPick(lang, "## 선택 영역\n", "## Selected Text\n", "## 選択範囲\n")) b.WriteString(c.SelectionText) b.WriteString("\n\n") } if len(c.Entities) > 0 { - b.WriteString("## 세계관 요소\n") + b.WriteString(langPick(lang, "## 세계관 요소\n", "## World Elements\n", "## 世界観要素\n")) for _, e := range c.Entities { - b.WriteString(fmt.Sprintf("- @%s — %s", e.Name, kindLabel(e.Kind))) + b.WriteString(fmt.Sprintf("- @%s — %s", e.Name, kindLabel(e.Kind, lang))) if e.Role != "" { b.WriteString(" / " + e.Role) } @@ -173,7 +257,7 @@ func buildUser(c Context) string { b.WriteString("\n") } if hasPlot(c.Plot) { - b.WriteString("## 플롯\n") + b.WriteString(langPick(lang, "## 플롯\n", "## Plot\n", "## プロット\n")) writeScene := func(tag string, s *plot.SceneBeats) { if s == nil || len(s.Beats) == 0 { return @@ -189,13 +273,13 @@ func buildUser(c Context) string { b.WriteString("\n") } } - writeScene("[이전 씬]", c.Plot.Prev) - writeScene("[현재 씬]", &c.Plot.Current) - writeScene("[다음 씬]", c.Plot.Next) + writeScene(langPick(lang, "[이전 씬]", "[prev scene]", "[前のシーン]"), c.Plot.Prev) + writeScene(langPick(lang, "[현재 씬]", "[current scene]", "[現在のシーン]"), &c.Plot.Current) + writeScene(langPick(lang, "[다음 씬]", "[next scene]", "[次のシーン]"), c.Plot.Next) b.WriteString("\n") } if len(c.Relationships) > 0 { - b.WriteString("## 관계\n") + b.WriteString(langPick(lang, "## 관계\n", "## Relationships\n", "## 関係\n")) for _, r := range c.Relationships { arrow := "→" if r.Bidirectional { @@ -211,7 +295,7 @@ func buildUser(c Context) string { b.WriteString("\n") } if len(c.Notes) > 0 { - b.WriteString("## 작가 주석\n") + b.WriteString(langPick(lang, "## 작가 주석\n", "## Author Notes\n", "## 作家メモ(注釈)\n")) for _, n := range c.Notes { b.WriteString("- ") b.WriteString(n.Body) @@ -220,11 +304,11 @@ func buildUser(c Context) string { b.WriteString("\n") } if c.Options.Tone != TonePresetMy && strings.TrimSpace(c.StyleNotes) != "" { - b.WriteString("## 작가 메모\n") + b.WriteString(langPick(lang, "## 작가 메모\n", "## Author Memo\n", "## 作家メモ\n")) b.WriteString(c.StyleNotes) b.WriteString("\n\n") } - b.WriteString("## 작가의 지시\n") + b.WriteString(langPick(lang, "## 작가의 지시\n", "## Writer's Instruction\n", "## 作家の指示\n")) b.WriteString(strings.TrimSpace(c.UserPrompt)) return b.String() } @@ -232,21 +316,53 @@ func buildUser(c Context) string { // renderProjectMeta returns a one-line "장르: X, Y · 분량: Z · 시점: W" // with empty pieces omitted. Returns empty string if all three are empty. // Unmapped LengthTarget / DefaultPOV values pass through as-is. -func renderProjectMeta(m ProjectMeta) string { +func renderProjectMeta(m ProjectMeta, lang string) string { parts := []string{} if len(m.Genres) > 0 { - parts = append(parts, "장르: "+strings.Join(m.Genres, ", ")) + parts = append(parts, langPick(lang, "장르: ", "Genres: ", "ジャンル: ")+strings.Join(m.Genres, ", ")) } if m.LengthTarget != "" { - parts = append(parts, "분량: "+mapLengthTarget(m.LengthTarget)) + parts = append(parts, langPick(lang, "분량: ", "Length: ", "分量: ")+mapLengthTarget(m.LengthTarget, lang)) } if m.DefaultPOV != "" { - parts = append(parts, "시점: "+mapDefaultPOV(m.DefaultPOV)) + parts = append(parts, langPick(lang, "시점: ", "POV: ", "視点: ")+mapDefaultPOV(m.DefaultPOV, lang)) } return strings.Join(parts, " · ") } -func mapLengthTarget(v string) string { +func mapLengthTarget(v, lang string) string { + if langIsJapanese(lang) { + switch v { + case "flash": + return "フラッシュ" + case "short": + return "短編" + case "novella": + return "中編" + case "novel": + return "長編" + case "series": + return "シリーズ" + default: + return v + } + } + if langIsEnglish(lang) { + switch v { + case "flash": + return "flash fiction" + case "short": + return "short story" + case "novella": + return "novella" + case "novel": + return "novel" + case "series": + return "series" + default: + return v + } + } switch v { case "flash": return "플래시" @@ -263,7 +379,31 @@ func mapLengthTarget(v string) string { } } -func mapDefaultPOV(v string) string { +func mapDefaultPOV(v, lang string) string { + if langIsJapanese(lang) { + switch v { + case "first": + return "一人称" + case "third_limited": + return "三人称限定" + case "omniscient": + return "全知" + default: + return v + } + } + if langIsEnglish(lang) { + switch v { + case "first": + return "first person" + case "third_limited": + return "third person limited" + case "omniscient": + return "omniscient" + default: + return v + } + } switch v { case "first": return "1인칭" @@ -276,7 +416,23 @@ func mapDefaultPOV(v string) string { } } -func kindLabel(k string) string { +func kindLabel(k, lang string) string { + if langIsEnglish(lang) { + return k + } + if langIsJapanese(lang) { + switch k { + case "character": + return "人物" + case "place": + return "場所" + case "item": + return "アイテム" + case "concept": + return "概念" + } + return k + } switch k { case "character": return "인물" diff --git a/engine/internal/companion/companion.go b/engine/internal/companion/companion.go index 3f3cc70..e18a413 100644 --- a/engine/internal/companion/companion.go +++ b/engine/internal/companion/companion.go @@ -55,6 +55,7 @@ type SendOptions struct { OutlineStructure string `json:"outline_structure,omitempty"` Intent RequestIntent `json:"intent,omitempty"` Scope string `json:"scope,omitempty"` + Language string `json:"language,omitempty"` } // ClientFactory and ProviderSource are shared with the ai package so the same @@ -406,7 +407,7 @@ func (s *Service) importLegacyHistoryIfNeeded(ctx context.Context, projectID str // CompactHistory replaces a long companion transcript with one assistant // summary message so future turns keep the useful context without replaying // every prior exchange. -func (s *Service) CompactHistory(ctx context.Context, projectID string, now func() int64) ([]session.Message, error) { +func (s *Service) CompactHistory(ctx context.Context, projectID, lang string, now func() int64) ([]session.Message, error) { sess, err := s.sessions.EnsureWorker(projectID) if err != nil { return nil, err @@ -425,7 +426,7 @@ func (s *Service) CompactHistory(ctx context.Context, projectID string, now func } compacted := []session.Message{{ Role: "assistant", - Content: compactTranscriptSummary(msgs), + Content: compactTranscriptSummary(msgs, lang), Timestamp: at, }} if err := session.RewriteMessages(path, compacted); err != nil { @@ -434,9 +435,9 @@ func (s *Service) CompactHistory(ctx context.Context, projectID string, now func return compacted, nil } -func (s *Service) CompactHistoryView(ctx context.Context, q HistoryQuery, now func() int64) ([]HistoryMessage, error) { +func (s *Service) CompactHistoryView(ctx context.Context, q HistoryQuery, lang string, now func() int64) ([]HistoryMessage, error) { if s.history == nil { - msgs, err := s.CompactHistory(ctx, q.ProjectID, now) + msgs, err := s.CompactHistory(ctx, q.ProjectID, lang, now) if err != nil { return nil, err } @@ -456,7 +457,7 @@ func (s *Service) CompactHistoryView(ctx context.Context, q HistoryQuery, now fu if now != nil { at = now() } - summary := compactTranscriptSummary(historyMessagesToSessionMessages(msgs)) + summary := compactTranscriptSummary(historyMessagesToSessionMessages(msgs), lang) if err := s.history.Clear(ctx, q); err != nil { return nil, err } @@ -562,17 +563,28 @@ func (s *Service) DeleteReference(ctx context.Context, projectID, id string) err return s.references.Delete(ctx, projectID, id) } -func compactTranscriptSummary(msgs []session.Message) string { +func compactTranscriptSummary(msgs []session.Message, lang string) string { start := 0 if len(msgs) > compactHistoryMaxMessages { start = len(msgs) - compactHistoryMaxMessages } var b strings.Builder - b.WriteString("이전 컴패니언 대화 요약\n\n") + b.WriteString(pickLang(lang, "이전 컴패니언 대화 요약\n\n", "Summary of the previous companion conversation\n\n", "以前のコンパニオン会話の要約\n\n")) if start > 0 { - b.WriteString("- 이전 메시지 ") - b.WriteString(strconv.Itoa(start)) - b.WriteString("개는 생략됨\n") + switch { + case isEnglish(lang): + b.WriteString("- ") + b.WriteString(strconv.Itoa(start)) + b.WriteString(" earlier messages omitted\n") + case isJapanese(lang): + b.WriteString("- 以前のメッセージ ") + b.WriteString(strconv.Itoa(start)) + b.WriteString(" 件は省略\n") + default: + b.WriteString("- 이전 메시지 ") + b.WriteString(strconv.Itoa(start)) + b.WriteString("개는 생략됨\n") + } } for _, msg := range msgs[start:] { text := compactSnippet(stripCompanionControlBlocks(msg.Content)) @@ -580,7 +592,7 @@ func compactTranscriptSummary(msgs []session.Message) string { continue } b.WriteString("- ") - b.WriteString(displayRole(msg.Role)) + b.WriteString(displayRole(msg.Role, lang)) b.WriteString(": ") b.WriteString(text) b.WriteString("\n") @@ -588,15 +600,15 @@ func compactTranscriptSummary(msgs []session.Message) string { return strings.TrimSpace(b.String()) } -func displayRole(role string) string { +func displayRole(role, lang string) string { switch strings.TrimSpace(role) { case "assistant": - return "컴패니언" + return pickLang(lang, "컴패니언", "Companion", "コンパニオン") case "user": - return "나" + return pickLang(lang, "나", "Me", "私") default: if strings.TrimSpace(role) == "" { - return "기록" + return pickLang(lang, "기록", "Log", "記録") } return strings.TrimSpace(role) } @@ -720,7 +732,7 @@ func (s *Service) SendWithCompanionOptionsAndImages(ctx context.Context, project if err != nil { return "", err } - return s.runner.start(ctx, projectID, nodeID, text, opts.Context, opts.OutlineStructure, opts.Intent, opts.Scope, normalized, now) + return s.runner.start(ctx, projectID, nodeID, text, opts.Context, opts.OutlineStructure, opts.Intent, opts.Scope, normalized, opts.Language, now) } // Cancel cancels an in-flight run. diff --git a/engine/internal/companion/companion_test.go b/engine/internal/companion/companion_test.go index 3278a9b..128cb02 100644 --- a/engine/internal/companion/companion_test.go +++ b/engine/internal/companion/companion_test.go @@ -863,7 +863,7 @@ func TestCompactHistory_RewritesTranscriptAsSummary(t *testing.T) { t.Fatal(err) } - msgs, err := svc.CompactHistory(context.Background(), projectID, func() int64 { return 2000 }) + msgs, err := svc.CompactHistory(context.Background(), projectID, "", func() int64 { return 2000 }) if err != nil { t.Fatal(err) } @@ -1056,7 +1056,7 @@ func TestApplyContextSelection_RemovesUncheckedCompanionSections(t *testing.T) { Memories: &off, } - text := buildContext(applyContextSelection(data, selection)) + text := buildContext(applyContextSelection(data, selection), "") for _, blocked := range []string{ "자의식에 관한 작품 개요", "인간의 개별성", @@ -1137,7 +1137,7 @@ func TestBuildContext_RendersReferencesByPurpose(t *testing.T) { }, }, } - text := buildContext(data) + text := buildContext(data, "") for _, want := range []string{ "## 추가 레퍼런스", "문체 참고", @@ -1163,7 +1163,7 @@ func TestApplyContextSelection_RemovesReferences(t *testing.T) { Status: ReferenceStatusActive, }}, } - text := buildContext(applyContextSelection(data, ai.ContextSelection{References: &off})) + text := buildContext(applyContextSelection(data, ai.ContextSelection{References: &off}), "") if strings.Contains(text, "프롬프트에 들어가면 안 되는") { t.Fatalf("unchecked reference still rendered:\n%s", text) } diff --git a/engine/internal/companion/feedback_test.go b/engine/internal/companion/feedback_test.go index b1ab63b..50e8841 100644 --- a/engine/internal/companion/feedback_test.go +++ b/engine/internal/companion/feedback_test.go @@ -10,7 +10,7 @@ func TestFriendlyToolLabel(t *testing.T) { "something_else": "도구 실행 중: something_else", } for name, want := range cases { - if got := friendlyToolLabel(name); got != want { + if got := friendlyToolLabel(name, ""); got != want { t.Errorf("friendlyToolLabel(%q) = %q, want %q", name, got, want) } } diff --git a/engine/internal/companion/intent.go b/engine/internal/companion/intent.go index d741609..6cc3ceb 100644 --- a/engine/internal/companion/intent.go +++ b/engine/internal/companion/intent.go @@ -121,7 +121,7 @@ func isSceneTextTarget(s string) bool { if containsAny(s, companionSceneBodyTerms) { return true } - if !containsAny(s, []string{"씬", "장면"}) { + if !containsAny(s, []string{"씬", "장면", "scene", "シーン", "場面"}) { return false } if containsAny(s, companionNonBodySceneTerms) { @@ -206,35 +206,74 @@ func recentAssistantOfferedSceneWrite(history []conversationMessage) bool { var companionSceneBodyTerms = []string{ "현재 씬", "현재 장면", "씬 본문", "장면 본문", "현재 본문", "현재 원고", "본문", "원고", "문장", "다음 씬", "다음 장면", + // English equivalents (input is lower-cased before matching). + "current scene", "this scene", "scene text", "the scene", + "manuscript", "prose", "the text", "next scene", "draft", + // Japanese equivalents. + "現在のシーン", "シーン本文", "場面の本文", "本文", "原稿", "文章", + "次のシーン", "現在の場面", } var companionSceneWriteTerms = []string{ "작성", "써", "쓰", "이어", "계속", "채워", "완성", + // English equivalents. + "write", "continue", "keep going", "finish", "fill", "complete", "draft", + // Japanese equivalents. + "書いて", "執筆", "続き", "続けて", "埋めて", "完成", "仕上げ", } var companionSceneRewriteTerms = []string{ "수정", "바꿔", "변경", "반영", "다듬", "고쳐", "재작성", "교정", "퇴고", + // English equivalents. + "rewrite", "revise", "edit", "fix", "polish", "proofread", "refine", + "improve", "tighten", + // Japanese equivalents. + "修正", "書き直", "変更", "反映", "推敲", "校正", "直して", "リライト", } var companionSceneFollowupTerms = []string{ "적용", "진행", "바로", "좋아", "그걸로", "그대로", "작성해", "써줘", "써 줘", "완성해", "본문 작성", "현재 씬 작성", "첫번째", "첫 번째", "1번", + // English equivalents. + "apply", "go ahead", "do it", "sounds good", "that one", "option 1", + "the first one", "proceed", "yes please", + // Japanese equivalents. + "適用", "進めて", "それで", "そのまま", "お願いします", "一番目", "1番", } var companionSceneOfferTargetTerms = []string{ "현재 씬 본문", "현재 장면 본문", "씬 본문", "장면 본문", "현재 원고", "본문 작성", "현재 씬", "현재 장면", "이어질 문장", "문장 제안", + // English equivalents. + "current scene", "scene text", "the scene", "next sentences", + "continuation", + // Japanese equivalents. + "現在のシーン本文", "シーン本文", "続きの文", "文の提案", } var companionSceneOfferActionTerms = []string{ "작성", "써드", "써 드", "새로 씁", "완성", "적용", "이어", "확장", "연결부", "문체", "톤 맞춰", + // English equivalents. + "write", "draft", "continue", "expand", "extend", "apply", + "match the tone", + // Japanese equivalents. + "書き", "適用", "続け", "拡張", "文体", "トーンに合わせ", } var companionSceneEmptyTerms = []string{ "비워", "초기화", "비우", "삭제", "지워", + // English equivalents. + "clear", "empty", "erase", "reset", "wipe", "delete", + // Japanese equivalents. + "空にして", "初期化", "削除", "消して", "クリア", } var companionNonBodySceneTerms = []string{ "비트", "플롯", "아웃라인", "목차", "구조", "나눠", "나누", "분할", "쪼개", + // English equivalents. + "beat", "plot", "outline", "structure", "split", "divide", + "table of contents", + // Japanese equivalents. + "ビート", "プロット", "アウトライン", "目次", "構成", "分割", "分けて", } diff --git a/engine/internal/companion/prompt.go b/engine/internal/companion/prompt.go index ce33fd9..451f7a5 100644 --- a/engine/internal/companion/prompt.go +++ b/engine/internal/companion/prompt.go @@ -8,6 +8,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/ai" "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/relationship" "github.com/devlikebear/linetta/engine/internal/thread" @@ -49,9 +50,35 @@ type SceneExcerpt struct { } // buildSystem returns the companion persona + tool/proposal rules. -func buildSystem() string { +func isEnglish(lang string) bool { + return strings.HasPrefix(lang, "en") +} + +func isJapanese(lang string) bool { + return strings.HasPrefix(lang, "ja") +} + +// pickLang returns the string for the app language; Korean is the default. +func pickLang(lang, ko, en, ja string) string { + if isEnglish(lang) { + return en + } + if isJapanese(lang) { + return ja + } + return ko +} + +func buildSystem(language string) string { + if isEnglish(language) { + return buildSystemEn() + } + if isJapanese(language) { + return buildSystemJa() + } var b strings.Builder - b.WriteString("당신은 한국어 소설 작가의 집필 동료입니다. 작가와 자연스럽게 대화하며 플롯·인물·전개를 함께 구상합니다.\n\n") + b.WriteString("당신은 한국어 소설 작가의 집필 동료입니다. 작가와 자연스럽게 대화하며 플롯·인물·전개를 함께 구상합니다.\n") + b.WriteString("이전 대화나 원고가 다른 언어여도 항상 한국어로 답하세요. 필요하면 원고 인용만 원문 언어로 하세요.\n\n") b.WriteString("도구가 제공되면 적극적으로 사용하세요: web_search는 최신 자료나 장르 레퍼런스를 찾고, web_fetch는 특정 URL 내용을 확인하며, linetta_apply_ops는 작품의 개요·시놉시스·아웃라인 트리·스토리라인·비트·세계관 요소(캐릭터·장소·아이템·스킬·마법·능력)·관계·씬·기억·팩트 자료집을 직접 갱신합니다.\n") b.WriteString("작품 내부 근거가 필요하면 ```linetta-query``` 블록으로 search_entities, search_manuscript(query, limit?), get_scene_text(node_id), list_scenes, list_beats, recall_memory를 호출하세요. search_manuscript는 본문 전체에서 고유명사·설정 묘사가 나오는 대목을 찾습니다. 결과의 node_id로 get_scene_text를 호출해 전문을 확인하고, 패러프레이즈에 약하므로 동의어가 의심되면 여러 표현으로 검색하세요.\n") b.WriteString("컨텍스트의 '작성된 본문 발췌'는 이미 작성된 실제 원고입니다. 캐릭터·관계·세계관 요소·전개 분석 요청에서는 이 본문을 우선 근거로 삼고, 본문이 제공되어 있는데 없다고 말하지 마세요.\n") @@ -92,25 +119,118 @@ func buildSystem() string { return b.String() } +// buildSystemEn is the English-language variant of buildSystem. +func buildSystemEn() string { + var b strings.Builder + b.WriteString("You are a writing companion for a fiction writer. You converse naturally with the writer to develop plots, characters, and story structure together.\n") + b.WriteString("Always respond in English, even if earlier conversation turns or the manuscript are written in another language. Quote manuscript text in its original language when needed.\n\n") + b.WriteString("When tools are available, use them actively: web_search finds current references and genre materials; web_fetch retrieves specific URLs; linetta_apply_ops directly updates the work's synopsis, outline tree, storylines, beats, world-building elements (characters, places, items, skills, abilities), relationships, scenes, memories, and fact cards.\n") + b.WriteString("When internal evidence is needed, call search_entities, search_manuscript(query, limit?), get_scene_text(node_id), list_scenes, list_beats, or recall_memory inside a ```linetta-query``` block. search_manuscript finds passages containing named entities or world-building descriptions across the full manuscript. Use the returned node_id with get_scene_text to read the full text; try synonym searches if exact matches are sparse.\n") + b.WriteString("The 'Written Manuscript Excerpts' in context are already-written, real manuscript text. When analysing characters, relationships, world-building, or plot, treat this text as primary evidence — do not say there is none when it is provided.\n") + b.WriteString("Terminology: 'outline/part/chapter/scene structure' refers to the left outline tree, updated with create_outline_node/create_scene/rename_outline_node/delete_outline_node/move_outline_node. 'Scene text/manuscript/actual prose' is updated with set_scene_text. 'Work synopsis/overview' text is updated with set_outline. 'Plot/storyline/beats' are updated with create_thread/add_beat.\n") + b.WriteString("For proofreading requests (spelling, spacing, grammar, awkward phrasing), preserve the original meaning, voice, proper nouns, and dialogue tone — make only necessary corrections. After applying, briefly list what changed.\n") + b.WriteString("If the writer has approved an idea, or explicitly asks to write/revise/add/generate/expand/save the synopsis, outline, storylines, beats, world-building elements, characters, relationships, places, items, scenes, or memories — do not just explain: call linetta_apply_ops. After applying, briefly state what changed; ask first if anything is uncertain.\n") + b.WriteString("Help sculpt the story: given a rough synopsis or a single sentence, use set_outline to set the work overview, create_outline_node/create_scene to build a visible outline tree, and add create_thread/add_beat to attach plot beats to those scenes. For a specific part/chapter/act, refine outline nodes and connect beats. For a scene rewrite/revision/expansion, replace the real scene prose with set_scene_text — do not just save a memory or beat.\n\n") + b.WriteString("Only when no tools are available, or when the writer wants a proposal to review, suggest concrete changes (outline tree creation/revision, storyline creation/revision, beat additions) using **exactly one** fenced block in this format. For simple conversation or Q&A, omit the block.\n\n") + b.WriteString("```linetta-proposal\n") + b.WriteString(`{"summary":"","ops":[ ... ]}` + "\n") + b.WriteString("```\n\n") + b.WriteString("Op types: create_outline_node{ref?,kind:container|leaf,label,title?,parent_node_id?|parent_node_ref?,after_node_id?|after_node_ref?}, rename_outline_node{node_id|node_ref,label?,title?}, delete_outline_node{node_id|node_ref}, move_outline_node{node_id|node_ref,direction:up|down}, create_scene{ref?,label,title?,after_node_id?|after_node_ref?}, set_scene_text{text,node_id?|node_ref?,allow_empty?}, create_thread{ref?,name,color?,summary?}, update_thread{thread_id,name?,color?,summary?}, add_beat{thread_id|thread_ref,node_id?|node_ref?,label,description?,intensity?}, update_beat{beat_id,label?,description?,intensity?}, delete_beat{beat_id}, set_outline{outline}, remember{text,category?}, create_entity{ref?,kind,name,role?,summary?,attributes?}, update_entity{entity_id,name?,role?,summary?,attributes?}, create_relationship{from|from_ref,to|to_ref,label,inverse_label?,notes?}, create_fact_card{ref?,claim,result,status,category?,node_id?|node_ref?,sources:[{url,title?,snippet?,accessed_at?}]}.\n") + b.WriteString("- Fact card rules: use create_fact_card only when at least one source URL exists. Do not save facts without sources[].url. Mark freshness/confidence with status (verified|uncertain|intentional_fiction|stale). Use short snippet summaries, not long quotes.\n") + b.WriteString("- ID rules (important): only use IDs actually present in the context's 'Scenes', 'Storylines', or 'World Elements & Relationships' lists. Never invent IDs.\n") + b.WriteString("- To rewrite/revise/expand the current scene, use set_scene_text{text:\"...\"}. Omit node_id to target the current scene. Only supply a node_id from context when modifying a different scene. Use allow_empty:true only when explicitly asked to clear the text.\n") + b.WriteString("- add_beat.node_id must be a node_id from the 'Scenes' list above. Omitting it attaches the beat to the current scene.\n") + b.WriteString("- Beats must belong to a storyline. Use an existing thread_id if one exists; for a new storyline, include create_thread (with ref) earlier in the same proposal and reference it with add_beat.thread_ref. Never guess thread_id.\n") + b.WriteString("- World elements and relationships follow the same rule. Reference existing elements by their id from the 'World Elements & Relationships' list; for new elements, use create_entity (with ref), then from_ref/to_ref in create_relationship.\n") + b.WriteString("- create_entity.kind must be one of character|place|item|concept (defaults to character). Use character for people, place for locations, item for objects/artefacts, concept for skills/magic/abilities/world rules. Put mechanics (effects, costs, constraints, weaknesses) in attributes as key-value pairs.\n") + b.WriteString("- Outline cleanup: if the context's 'Outline Tree' already has a node_id for a part/chapter/scene, do not recreate it — use rename_outline_node/delete_outline_node/move_outline_node instead. Only use create_outline_node (with ref) when adding new structure.\n") + b.WriteString("- When building new structure, follow the 'Outline Structure Preset' in context for hierarchy and label conventions. Create parent→child levels using parent_node_ref, then attach beats to created scenes with add_beat.node_ref. A create_outline_node without parent/after goes to the root.\n") + b.WriteString("- To add a single scene next to the current one, use create_scene (with ref), then add_beat.node_ref to attach beats (omit node_id for the current scene). For bidirectional relationships, supply inverse_label in create_relationship.\n") + b.WriteString("Examples:\n") + b.WriteString("```\n") + b.WriteString(`{"summary":"Rewrite current scene","ops":[{"op":"set_scene_text","text":"First paragraph of new scene\nContinuing sentence\n\nNext paragraph"}]}` + "\n") + b.WriteString(`{"summary":"Add revenge storyline","ops":[{"op":"create_thread","ref":"t1","name":"Revenge"},{"op":"add_beat","thread_ref":"t1","label":"Resolution","description":"The protagonist decides to seek revenge"}]}` + "\n") + b.WriteString(`{"summary":"Draft Part 1 outline with plot beats","ops":[{"op":"create_outline_node","ref":"p1","kind":"container","label":"Part 1","title":"The Pursuit Begins"},{"op":"create_outline_node","ref":"c1","kind":"container","parent_node_ref":"p1","label":"Chapter 1"},{"op":"create_outline_node","ref":"s1","kind":"leaf","parent_node_ref":"c1","label":"Scene 1","title":"The Missing Message"},{"op":"create_thread","ref":"t1","name":"Part 1 Chase Line"},{"op":"add_beat","thread_ref":"t1","node_ref":"s1","label":"Clue Found","description":"The protagonist discovers the first clue in a deleted message"}]}` + "\n") + b.WriteString("```\n") + b.WriteString("The tool schema and proposal-block op schema are identical. linetta_apply_ops takes summary and ops_json; ops_json is the op array as a JSON string. Follow the same ID/ref rules when using linetta_apply_ops — never invent IDs.\n") + b.WriteString("What you learned about the work's setting and the writer's preferences in previous turns is given below under 'Memories'. If a new fact worth keeping emerges (writer preference, world rule, etc.) and the writer's intent is clear, save it with a remember op in linetta_apply_ops; otherwise, propose it.\n\n") + b.WriteString("When offering the writer a choice among candidates (titles, names, plot directions, tone, etc.), do not list them inline — use **exactly one** fenced block in the format below so the writer can select with a button.\n") + b.WriteString("```linetta-choices\n") + b.WriteString(`{"prompt":"","options":["option1","option2","option3"],"allow_custom":true}` + "\n") + b.WriteString("```\n") + b.WriteString("- Provide at least two options; the writer sends the chosen text back verbatim, so keep options short and clear.\n") + b.WriteString("- When allow_custom is true, a 'write your own' button is shown as well (set true when the writer may supply their own answer).\n") + b.WriteString("- Add brief context/reasoning before the block, but do not repeat the option list in prose. Never use linetta-choices and linetta-proposal in the same turn (a choice is your turn to ask the writer back). Omit the block for plain conversation or explanation.\n") + return b.String() +} + +// buildSystemJa is the Japanese-language variant of buildSystem. +func buildSystemJa() string { + var b strings.Builder + b.WriteString("あなたは小説家の執筆コンパニオンです。作家と自然に対話しながら、プロット・人物・展開を一緒に構想します。\n") + b.WriteString("以前の会話や原稿が別の言語でも、常に日本語で答えてください。必要な場合のみ原稿の引用を原文の言語で行ってください。\n\n") + b.WriteString("ツールが提供されている場合は積極的に使ってください: web_search は最新資料やジャンルのリファレンスを探し、web_fetch は特定 URL の内容を確認し、linetta_apply_ops は作品の概要・シノプシス・アウトラインツリー・ストーリーライン・ビート・世界観要素(キャラクター・場所・アイテム・スキル・魔法・能力)・関係・シーン・記憶・ファクト資料集を直接更新します。\n") + b.WriteString("作品内部の根拠が必要な場合は ```linetta-query``` ブロックで search_entities, search_manuscript(query, limit?), get_scene_text(node_id), list_scenes, list_beats, recall_memory を呼び出してください。search_manuscript は本文全体から固有名詞や設定描写が現れる箇所を探します。結果の node_id で get_scene_text を呼んで全文を確認し、言い換えに弱いため同義語が疑われる場合は複数の表現で検索してください。\n") + b.WriteString("コンテキストの「執筆済み本文の抜粋」はすでに書かれた実際の原稿です。キャラクター・関係・世界観要素・展開の分析依頼では、この本文を第一の根拠とし、本文が提供されているのに無いと言わないでください。\n") + b.WriteString("用語の区別: 「アウトライン/目次/部/章/シーン構成」は左のアウトラインツリーで、create_outline_node/create_scene/rename_outline_node/delete_outline_node/move_outline_node で更新します。「シーン本文/原稿/現在の場面の実際の文章」は set_scene_text で更新します。「作品概要/シノプシス」のテキストは set_outline で更新します。「プロット/ストーリーライン/ビート」は create_thread/add_beat で更新します。\n") + b.WriteString("誤字・脱字・文法・不自然な文の校正のような推敲依頼では、原文の意味・文体・固有名詞・台詞のトーンを保ち、必要な修正のみ行ってください。適用後は変更点のリストを短く添えてください。\n") + b.WriteString("作家がアイデアを承認した場合、または作品/小説の概要・シノプシス・アウトライン・ストーリーライン・ビート・世界観要素・キャラクター・関係・場所・アイテム・スキル・魔法・能力・シーン・記憶の作成/修正/追加/生成/具体化/細分化/分割/拡張/反映/保存を明確に依頼した場合は、説明で終わらせず必ず linetta_apply_ops を呼び出してください。適用後は何を変えたか短く伝え、不確実な変更は先に質問してください。\n") + b.WriteString("彫刻のように執筆を支援します: 粗いシノプシスや一文を受け取ったら set_outline で作品概要を整え、create_outline_node/create_scene で見えるアウトラインツリーを作り、必要ならそのシーンに create_thread/add_beat でプロットビートを付けてください。特定のパート・章・幕を依頼されたらアウトラインノードを細分化しビートを繋げます。特定シーンの本文執筆や書き直し・修正・推敲・拡張の依頼には、記憶やビートだけ保存せず set_scene_text で実際のシーン原稿を置き換えてください。\n\n") + b.WriteString("ツールが無い場合、または作家がレビュー用の提案を望む場合のみ、具体的な変更(アウトラインツリーの作成/修正、ストーリーラインの作成/修正、ビートの追加/修正/削除、作品概要/シノプシスの設定)を次の形式のフェンスドブロック**ちょうど一つ**で提案してください。単純な会話や質問への回答ではブロックを入れないでください。\n\n") + b.WriteString("```linetta-proposal\n") + b.WriteString(`{"summary":"<一行要約>","ops":[ ... ]}` + "\n") + b.WriteString("```\n\n") + b.WriteString("op の種類: create_outline_node{ref?,kind:container|leaf,label,title?,parent_node_id?|parent_node_ref?,after_node_id?|after_node_ref?}, rename_outline_node{node_id|node_ref,label?,title?}, delete_outline_node{node_id|node_ref}, move_outline_node{node_id|node_ref,direction:up|down}, create_scene{ref?,label,title?,after_node_id?|after_node_ref?}, set_scene_text{text,node_id?|node_ref?,allow_empty?}, create_thread{ref?,name,color?,summary?}, update_thread{thread_id,name?,color?,summary?}, add_beat{thread_id|thread_ref,node_id?|node_ref?,label,description?,intensity?}, update_beat{beat_id,label?,description?,intensity?}, delete_beat{beat_id}, set_outline{outline}, remember{text,category?}, create_entity{ref?,kind,name,role?,summary?,attributes?}, update_entity{entity_id,name?,role?,summary?,attributes?}, create_relationship{from|from_ref,to|to_ref,label,inverse_label?,notes?}, create_fact_card{ref?,claim,result,status,category?,node_id?|node_ref?,sources:[{url,title?,snippet?,accessed_at?}]}.\n") + b.WriteString("- ファクト資料集の保存ルール: create_fact_card は最低1つの出典 URL がある場合のみ使ってください。sources[].url の無い資料は保存せず、鮮度・不確実性は status(verified|uncertain|intentional_fiction|stale) で表示してください。出典本文は長い引用ではなく短い要約/snippet のみ入れてください。\n") + b.WriteString("- id ルール(重要): コンテキストの「シーン」「ストーリーライン」「世界観要素・関係」リストに実際に与えられた id のみ使ってください。id を決して捏造しないでください。\n") + b.WriteString("- 現在のシーン本文の書き直し/修正/拡張/推敲を依頼されたら set_scene_text{text:\"...\"} を使ってください。node_id を省略すると現在のシーンが対象です。他のシーンを変える場合のみコンテキストの実際の node_id を入れてください。本文を空にする依頼が明確な場合のみ allow_empty:true を使ってください。\n") + b.WriteString("- add_beat.node_id は上の「シーン」リストの node_id の一つです。省略すると現在のシーンに付きます。\n") + b.WriteString("- ビートは必ずストーリーラインに属します。既存のストーリーラインがあればその thread_id を、新しい筋なら同じ提案に create_thread(ref 付き)を先に入れ、add_beat.thread_ref でその ref を参照してください。thread_id を推測しないでください。\n") + b.WriteString("- 世界観要素と関係も同じルールです。既存要素は「世界観要素・関係」リストの id で参照し、新要素は create_entity(ref 付き)の後、create_relationship の from_ref/to_ref でその ref を参照してください。\n") + b.WriteString("- create_entity.kind は必ず character|place|item|concept のいずれかです(省略時は character と見なされます)。キャラクター/人物は character、場所は place、アイテム/物/遺物は item、スキル/魔法/能力/世界観ルールは concept です。効果・コスト・制約・弱点のような一貫性管理に必要な情報は attributes に key-value で入れてください。\n") + b.WriteString("- アウトライン整理ルール: コンテキストの「アウトラインツリー」に既存の node_id があれば、同じ部/章/シーンを作り直さず rename_outline_node/delete_outline_node/move_outline_node で整理してください。新しい構造を追加する場合のみ create_outline_node(ref 付き)を使ってください。\n") + b.WriteString("- 新しいアウトライン構造は、コンテキストの「アウトライン構造プリセット」があればその階層とラベル例に従い、parent_node_ref で上位→下位の階層を作った後、add_beat.node_ref で作成したシーンにビートを付けます。parent/after の無い create_outline_node はルートに作られます。\n") + b.WriteString("- 新しいシーンを一つだけ現在の位置の隣に作る場合は create_scene(ref 付き)の後、add_beat.node_ref でそのシーンにビートを付けます(node_id 省略時は現在のシーン)。関係を双方向にするには create_relationship に inverse_label を与えてください。\n") + b.WriteString("例:\n") + b.WriteString("```\n") + b.WriteString(`{"summary":"現在のシーン本文を書き直す","ops":[{"op":"set_scene_text","text":"新しいシーン本文の最初の段落\n続く文章\n\n次の段落"}]}` + "\n") + b.WriteString(`{"summary":"復讐劇ラインを追加","ops":[{"op":"create_thread","ref":"t1","name":"復讐劇"},{"op":"add_beat","thread_ref":"t1","label":"決意","description":"主人公が復讐を誓う"}]}` + "\n") + b.WriteString(`{"summary":"第1部のアウトラインとプロットビートを作成","ops":[{"op":"create_outline_node","ref":"p1","kind":"container","label":"第1部","title":"追跡の始まり"},{"op":"create_outline_node","ref":"c1","kind":"container","parent_node_ref":"p1","label":"第1章"},{"op":"create_outline_node","ref":"s1","kind":"leaf","parent_node_ref":"c1","label":"シーン 1","title":"消えたメッセージ"},{"op":"create_thread","ref":"t1","name":"第1部追跡ライン"},{"op":"add_beat","thread_ref":"t1","node_ref":"s1","label":"手がかり発見","description":"主人公が消されたメッセージ記録から最初の手がかりを得る"}]}` + "\n") + b.WriteString("```\n") + b.WriteString("ツール適用と提案ブロックの op スキーマは同一です。linetta_apply_ops の入力は summary と ops_json です。ops_json には上の op 配列を JSON 文字列として入れてください。linetta_apply_ops を使う際も上の id/ref ルールを守り、id を捏造しないでください。\n") + b.WriteString("以前の会話で分かった作品設定・作家の好みは下の「記憶」に与えられます。記憶する価値のある新しい事実(作家の好み、世界観ルールなど)は、作家の意図が明確なら linetta_apply_ops の remember op で保存し、そうでなければ remember op で提案してください。\n\n") + b.WriteString("作家に複数の候補から一つ選んでもらう場合(タイトル・名前・展開方向・トーンなど)は、本文にリストで並べず、下の形式のフェンスドブロック**ちょうど一つ**で提示してください。作家がボタンですぐ選べます。\n") + b.WriteString("```linetta-choices\n") + b.WriteString(`{"prompt":"<何を選ぶかの一行>","options":["候補1","候補2","候補3"],"allow_custom":true}` + "\n") + b.WriteString("```\n") + b.WriteString("- options は2つ以上で、作家がそのテキストをそのまま返信として送ります。短く明確に書いてください。\n") + b.WriteString("- allow_custom が true の場合「自由入力」ボタンも表示されます(作家が自分で答えを書ける場合に true)。\n") + b.WriteString("- ブロックの前の本文には文脈/理由を短く書き、候補リスト自体は本文に重複させないでください。linetta-choices と linetta-proposal を同じターンで併用しないでください(選択肢は作家に問い返す番です)。単純な会話・説明にはこのブロックを入れないでください。\n") + return b.String() +} + // buildContext renders the project state as a single user-role message body. -func buildContext(d PromptData) string { +func buildContext(d PromptData, language string) string { + lbl := func(ko, en, ja string) string { + return pickLang(language, ko, en, ja) + } var b strings.Builder if s := strings.TrimSpace(d.Outline); s != "" { - b.WriteString("## 작품 개요\n") + b.WriteString(lbl("## 작품 개요\n", "## Synopsis\n", "## 作品概要\n")) b.WriteString(s) b.WriteString("\n\n") } if s := strings.TrimSpace(d.OutlineStructure); s != "" { - b.WriteString("## 아웃라인 구조 프리셋\n") + b.WriteString(lbl("## 아웃라인 구조 프리셋\n", "## Outline Structure Preset\n", "## アウトライン構造プリセット\n")) b.WriteString(s) b.WriteString("\n") - b.WriteString("아웃라인 트리를 새로 만들거나 정리할 때 이 계층과 라벨 예시를 따르세요.\n\n") + b.WriteString(lbl("아웃라인 트리를 새로 만들거나 정리할 때 이 계층과 라벨 예시를 따르세요.\n\n", "Follow these hierarchy levels and label examples when creating or reorganizing the outline tree.\n\n", "アウトラインツリーを新規作成・整理する際は、この階層とラベル例に従ってください。\n\n")) } if len(d.OutlineNodes) > 0 { - b.WriteString("## 아웃라인 트리 (기존 구조 — node_id)\n") + b.WriteString(lbl("## 아웃라인 트리 (기존 구조 — node_id)\n", "## Outline Tree (existing structure — node_id)\n", "## アウトラインツリー(既存構造 — node_id)\n")) for _, n := range d.OutlineNodes { indent := strings.Repeat(" ", n.Depth) - line := fmt.Sprintf("%s- [%s] (%s) %s", indent, n.ID, n.Kind, n.Label) + line := fmt.Sprintf("%s- [%s] (%s) %s", indent, n.ID, n.Kind, node.DisplayLabel(n.Label, language)) if strings.TrimSpace(n.Title) != "" { line += " — " + strings.TrimSpace(n.Title) } @@ -119,14 +239,14 @@ func buildContext(d PromptData) string { b.WriteString("\n") } if len(d.Memories) > 0 { - b.WriteString("## 기억\n") + b.WriteString(lbl("## 기억\n", "## Memories\n", "## 記憶\n")) for _, m := range d.Memories { b.WriteString("- " + m + "\n") } b.WriteString("\n") } if len(d.Facts) > 0 { - b.WriteString("## 팩트 자료집\n") + b.WriteString(lbl("## 팩트 자료집\n", "## Fact Dossier\n", "## ファクト資料集\n")) for _, f := range d.Facts { line := fmt.Sprintf("- [%s] (%s) %s", f.ID, f.Status, f.Claim) if strings.TrimSpace(f.Category) != "" { @@ -150,8 +270,8 @@ func buildContext(d PromptData) string { b.WriteString("\n") } if len(d.References) > 0 { - b.WriteString("## 추가 레퍼런스\n") - b.WriteString("작가가 이번 요청에 참고하라고 직접 추가한 자료입니다. 목적 지시를 우선해서 사용하세요.\n") + b.WriteString(lbl("## 추가 레퍼런스\n", "## Additional References\n", "## 追加リファレンス\n")) + b.WriteString(lbl("작가가 이번 요청에 참고하라고 직접 추가한 자료입니다. 목적 지시를 우선해서 사용하세요.\n", "These materials were added by the writer for this request. Prioritize the purpose instructions.\n", "作家がこのリクエストのために直接追加した資料です。目的の指示を優先して使ってください。\n")) for _, r := range d.References { if r.Status == ReferenceStatusDisabled { continue @@ -160,51 +280,51 @@ func buildContext(d PromptData) string { if text == "" { continue } - b.WriteString(fmt.Sprintf("### %s — %s\n", purposeLabel(r.Purpose), strings.TrimSpace(r.Title))) - b.WriteString(referencePurposeInstruction(r.Purpose)) + b.WriteString(fmt.Sprintf("### %s — %s\n", purposeLabel(r.Purpose, language), strings.TrimSpace(r.Title))) + b.WriteString(referencePurposeInstruction(r.Purpose, language)) b.WriteString("\n") if r.Status == ReferenceStatusSummarized { - b.WriteString("(요약 사용 중)\n") + b.WriteString(lbl("(요약 사용 중)\n", "(summary in use)\n", "(要約使用中)\n")) } b.WriteString(text) b.WriteString("\n\n") } } if len(d.SceneExcerpts) > 0 { - b.WriteString("## 작성된 본문 발췌\n") + b.WriteString(lbl("## 작성된 본문 발췌\n", "## Written Manuscript Excerpts\n", "## 執筆済み本文の抜粋\n")) for _, s := range d.SceneExcerpts { if strings.TrimSpace(s.Text) == "" { continue } current := "" if s.IsCurrent { - current = " (현재 씬)" + current = lbl(" (현재 씬)", " (current scene)", "(現在のシーン)") } - b.WriteString(fmt.Sprintf("### [%s] %s%s\n", s.NodeID, s.Label, current)) + b.WriteString(fmt.Sprintf("### [%s] %s%s\n", s.NodeID, node.DisplayLabel(s.Label, language), current)) b.WriteString(strings.TrimSpace(s.Text)) b.WriteString("\n\n") } } if d.HasSpine && hasSpineBeats(d.Spine) { - b.WriteString("## 플롯\n") - writeScene(&b, "[이전 씬]", d.Spine.Prev) - writeSceneVal(&b, "[현재 씬]", d.Spine.Current) - writeScene(&b, "[다음 씬]", d.Spine.Next) + b.WriteString(lbl("## 플롯\n", "## Plot\n", "## プロット\n")) + writeScene(&b, lbl("[이전 씬]", "[prev scene]", "[前のシーン]"), d.Spine.Prev) + writeSceneVal(&b, lbl("[현재 씬]", "[current scene]", "[現在のシーン]"), d.Spine.Current) + writeScene(&b, lbl("[다음 씬]", "[next scene]", "[次のシーン]"), d.Spine.Next) b.WriteString("\n") } if d.HasSpine { - b.WriteString("## 씬 (비트를 붙일 수 있는 실제 씬 — node_id)\n") + b.WriteString(lbl("## 씬 (비트를 붙일 수 있는 실제 씬 — node_id)\n", "## Scenes (actual scenes that can have beats attached — node_id)\n", "## シーン(ビートを付けられる実際のシーン — node_id)\n")) if d.Spine.Prev != nil { - b.WriteString(fmt.Sprintf("- [%s] %s (이전 씬)\n", d.Spine.Prev.NodeID, d.Spine.Prev.Label)) + b.WriteString(fmt.Sprintf("- [%s] %s "+lbl("(이전 씬)", "(prev scene)", "(前のシーン)")+"\n", d.Spine.Prev.NodeID, node.DisplayLabel(d.Spine.Prev.Label, language))) } - b.WriteString(fmt.Sprintf("- [%s] %s (현재 씬)\n", d.Spine.Current.NodeID, d.Spine.Current.Label)) + b.WriteString(fmt.Sprintf("- [%s] %s "+lbl("(현재 씬)", "(current scene)", "(現在のシーン)")+"\n", d.Spine.Current.NodeID, node.DisplayLabel(d.Spine.Current.Label, language))) if d.Spine.Next != nil { - b.WriteString(fmt.Sprintf("- [%s] %s (다음 씬)\n", d.Spine.Next.NodeID, d.Spine.Next.Label)) + b.WriteString(fmt.Sprintf("- [%s] %s "+lbl("(다음 씬)", "(next scene)", "(次のシーン)")+"\n", d.Spine.Next.NodeID, node.DisplayLabel(d.Spine.Next.Label, language))) } b.WriteString("\n") } if len(d.Threads) > 0 { - b.WriteString("## 스토리라인\n") + b.WriteString(lbl("## 스토리라인\n", "## Storylines\n", "## ストーリーライン\n")) for _, t := range d.Threads { line := fmt.Sprintf("- [%s] %s", t.ID, t.Name) if t.Summary != "" { @@ -215,11 +335,11 @@ func buildContext(d PromptData) string { b.WriteString("\n") } if len(d.Entities) > 0 || len(d.Relationships) > 0 { - b.WriteString("## 세계관 요소·관계\n") + b.WriteString(lbl("## 세계관 요소·관계\n", "## World Elements & Relationships\n", "## 世界観要素・関係\n")) nameByID := map[string]string{} for _, e := range d.Entities { nameByID[e.ID] = e.Name - line := fmt.Sprintf("- [%s] (%s) %s", e.ID, kindLabel(e.Kind), e.Name) + line := fmt.Sprintf("- [%s] (%s) %s", e.ID, kindLabel(e.Kind, language), e.Name) if e.Role != "" { line += " / " + e.Role } @@ -455,7 +575,7 @@ func renderCompanionPlotPreview(d PromptData) string { func renderCompanionEntitiesPreview(entities []entity.Entity) string { var b strings.Builder for _, e := range entities { - line := fmt.Sprintf("- [%s] (%s) %s", e.ID, kindLabel(e.Kind), e.Name) + line := fmt.Sprintf("- [%s] (%s) %s", e.ID, kindLabel(e.Kind, ""), e.Name) if e.Role != "" { line += " / " + e.Role } @@ -540,7 +660,7 @@ func activeReferences(refs []Reference) []Reference { func renderReferencesPreview(refs []Reference) string { var b strings.Builder for _, r := range activeReferences(refs) { - b.WriteString(fmt.Sprintf("- %s · %s", purposeLabel(r.Purpose), strings.TrimSpace(r.Title))) + b.WriteString(fmt.Sprintf("- %s · %s", purposeLabel(r.Purpose, ""), strings.TrimSpace(r.Title))) if r.Status == ReferenceStatusSummarized { b.WriteString(" · 요약") } @@ -552,7 +672,31 @@ func renderReferencesPreview(refs []Reference) string { return b.String() } -func referencePurposeInstruction(purpose string) string { +func referencePurposeInstruction(purpose string, lang string) string { + if isJapanese(lang) { + switch normalizeReferencePurpose(purpose) { + case ReferencePurposeStyle: + return "目的: 文体参考。文のリズム、語彙、視点、距離感のみ参考にし、内容や固有の表現をそのまま複製しないでください。" + case ReferencePurposeCanon: + return "目的: 設定/世界観。作品内部の事実として優先的に反映し、現在のシーン本文と矛盾する場合は矛盾を短く伝えてください。" + case ReferencePurposeConstraint: + return "目的: 禁止/注意事項。以下の制限を優先的に守り、破らざるを得ない場合は先に説明してください。" + default: + return "目的: 内容参考。場面、事実、感情的文脈の根拠として活用してください。" + } + } + if isEnglish(lang) { + switch normalizeReferencePurpose(purpose) { + case ReferencePurposeStyle: + return "Purpose: Style reference. Borrow only sentence rhythm, vocabulary, POV, and narrative distance — do not copy content or unique expressions verbatim." + case ReferencePurposeCanon: + return "Purpose: Canon/worldbuilding. Treat as established in-universe fact; if it conflicts with the current scene, note the conflict briefly." + case ReferencePurposeConstraint: + return "Purpose: Prohibition/caution. Observe the restrictions below first; if you must break them, explain why before proceeding." + default: + return "Purpose: Content reference. Use as evidence for scenes, facts, and emotional context." + } + } switch normalizeReferencePurpose(purpose) { case ReferencePurposeStyle: return "목적: 문체 참고. 문장 리듬, 어휘, 시점, 거리감만 참고하고 내용이나 고유 표현을 그대로 복사하지 마세요." @@ -565,7 +709,31 @@ func referencePurposeInstruction(purpose string) string { } } -func purposeLabel(purpose string) string { +func purposeLabel(purpose string, lang string) string { + if isJapanese(lang) { + switch normalizeReferencePurpose(purpose) { + case ReferencePurposeStyle: + return "文体参考" + case ReferencePurposeCanon: + return "設定/世界観" + case ReferencePurposeConstraint: + return "禁止/注意" + default: + return "内容参考" + } + } + if isEnglish(lang) { + switch normalizeReferencePurpose(purpose) { + case ReferencePurposeStyle: + return "Style ref." + case ReferencePurposeCanon: + return "Canon" + case ReferencePurposeConstraint: + return "Constraint" + default: + return "Content ref." + } + } switch normalizeReferencePurpose(purpose) { case ReferencePurposeStyle: return "문체 참고" @@ -614,7 +782,23 @@ func writeSceneVal(b *strings.Builder, tag string, s plot.SceneBeats) { } } -func kindLabel(k string) string { +func kindLabel(k string, lang string) string { + if isEnglish(lang) { + return k + } + if isJapanese(lang) { + switch k { + case "character": + return "人物" + case "place": + return "場所" + case "item": + return "アイテム" + case "concept": + return "概念" + } + return k + } switch k { case "character": return "인물" diff --git a/engine/internal/companion/prompt_test.go b/engine/internal/companion/prompt_test.go index 687f18b..a9b823f 100644 --- a/engine/internal/companion/prompt_test.go +++ b/engine/internal/companion/prompt_test.go @@ -11,7 +11,7 @@ import ( ) func TestBuildSystem_HasProposalRules(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"집필 동료", "linetta-proposal", "create_thread", "add_beat", "linetta_apply_ops"} { if !strings.Contains(s, want) { t.Fatalf("system missing %q", want) @@ -19,8 +19,64 @@ func TestBuildSystem_HasProposalRules(t *testing.T) { } } +func TestBuildSystem_EnglishLanguage(t *testing.T) { + s := buildSystem("en") + for _, want := range []string{"writing companion for a fiction writer", "linetta-proposal", "create_thread", "add_beat", "linetta_apply_ops"} { + if !strings.Contains(s, want) { + t.Fatalf("english system missing %q", want) + } + } + if strings.Contains(s, "집필 동료") { + t.Fatal("english system prompt still contains Korean persona line") + } +} + +func TestBuildContext_EnglishLabels(t *testing.T) { + d := PromptData{ + Outline: "A detective hunts a vanished message.", + Memories: []string{"The writer prefers short sentences."}, + } + out := buildContext(d, "en") + for _, want := range []string{"## Synopsis", "## Memories"} { + if !strings.Contains(out, want) { + t.Fatalf("english context missing %q", want) + } + } + if strings.Contains(out, "## 작품 개요") || strings.Contains(out, "## 기억") { + t.Fatalf("english context still contains Korean headers: %q", out) + } +} + +func TestBuildSystem_JapaneseLanguage(t *testing.T) { + s := buildSystem("ja") + for _, want := range []string{"執筆コンパニオン", "常に日本語で答えて", "linetta-proposal", "create_thread", "add_beat", "linetta_apply_ops"} { + if !strings.Contains(s, want) { + t.Fatalf("japanese system missing %q", want) + } + } + if strings.Contains(s, "집필 동료") { + t.Fatal("japanese system prompt still contains Korean persona line") + } +} + +func TestBuildContext_JapaneseLabels(t *testing.T) { + d := PromptData{ + Outline: "探偵が消えたメッセージを追う。", + Memories: []string{"作家は短い文を好む。"}, + } + out := buildContext(d, "ja") + for _, want := range []string{"## 作品概要", "## 記憶"} { + if !strings.Contains(out, want) { + t.Fatalf("japanese context missing %q", want) + } + } + if strings.Contains(out, "## 작품 개요") || strings.Contains(out, "## 기억") { + t.Fatalf("japanese context still contains Korean headers: %q", out) + } +} + func TestBuildSystem_MentionsWebTools(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"web_search", "web_fetch"} { if !strings.Contains(s, want) { t.Fatalf("buildSystem missing %q", want) @@ -29,7 +85,7 @@ func TestBuildSystem_MentionsWebTools(t *testing.T) { } func TestBuildSystem_MentionsManuscriptSearchTool(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"search_manuscript", "get_scene_text", "본문 전체", "node_id"} { if !strings.Contains(s, want) { t.Fatalf("buildSystem missing manuscript search guidance %q", want) @@ -38,7 +94,7 @@ func TestBuildSystem_MentionsManuscriptSearchTool(t *testing.T) { } func TestBuildSystem_MentionsProofreadRules(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"맞춤법", "고유명사", "대사 톤", "변경 목록"} { if !strings.Contains(s, want) { t.Fatalf("buildSystem missing proofread rule %q", want) @@ -55,7 +111,7 @@ func TestBuildContext_RendersSections(t *testing.T) { }, Threads: []thread.Thread{{ID: "th1", Name: "메인플롯", Summary: "중심 줄기"}}, } - out := buildContext(d) + out := buildContext(d, "") for _, want := range []string{"## 작품 개요", "전체 개요", "## 플롯", "[현재 씬]", "메인", "## 스토리라인", "[th1] 메인플롯"} { if !strings.Contains(out, want) { t.Fatalf("context missing %q in:\n%s", want, out) @@ -64,13 +120,13 @@ func TestBuildContext_RendersSections(t *testing.T) { } func TestBuildContext_EmptyIsBlank(t *testing.T) { - if out := buildContext(PromptData{}); out != "" { + if out := buildContext(PromptData{}, ""); out != "" { t.Fatalf("empty data should yield empty context, got %q", out) } } func TestBuildContext_RendersMemories(t *testing.T) { - out := buildContext(PromptData{Memories: []string{"작가는 단문을 선호"}}) + out := buildContext(PromptData{Memories: []string{"작가는 단문을 선호"}}, "") if !strings.Contains(out, "## 기억") || !strings.Contains(out, "작가는 단문을 선호") { t.Fatalf("memories not rendered:\n%s", out) } @@ -83,7 +139,7 @@ func TestBuildContext_RendersSceneExcerpts(t *testing.T) { Label: "1부 / 1장 / 씬 1", Text: "해진은 민호를 믿지 못한 채 항구를 떠났다.", }}, - }) + }, "") for _, want := range []string{"## 작성된 본문 발췌", "[n1] 1부 / 1장 / 씬 1", "해진은 민호를 믿지 못한 채"} { if !strings.Contains(out, want) { t.Fatalf("context missing %q in:\n%s", want, out) @@ -92,7 +148,7 @@ func TestBuildContext_RendersSceneExcerpts(t *testing.T) { } func TestBuildSystem_MentionsRemember(t *testing.T) { - s := buildSystem() + s := buildSystem("") if !strings.Contains(s, "remember") || !strings.Contains(s, "기억") { t.Fatal("buildSystem missing remember/memory guidance") } @@ -105,7 +161,7 @@ func TestBuildContext_ExposesSceneIDs(t *testing.T) { Current: plot.SceneBeats{NodeID: "node-123", Label: "1부 / 1장 / 씬1"}, }, } - out := buildContext(d) + out := buildContext(d, "") if !strings.Contains(out, "## 씬") || !strings.Contains(out, "node-123") { t.Fatalf("scene ids not exposed:\n%s", out) } @@ -118,7 +174,7 @@ func TestBuildContext_RendersOutlineTreeIDs(t *testing.T) { {ID: "chapter-1", ParentID: "part-1", Kind: "container", Label: "1장", Title: "불안한 아침", Depth: 1}, {ID: "scene-1", ParentID: "chapter-1", Kind: "leaf", Label: "씬 1", Title: "조각난 자아", Depth: 2}, }, - }) + }, "") for _, want := range []string{"## 아웃라인 트리", "[part-1]", "1부", "[chapter-1]", "1장", "[scene-1]", "씬 1"} { if !strings.Contains(out, want) { t.Fatalf("outline tree context missing %q in:\n%s", want, out) @@ -129,7 +185,7 @@ func TestBuildContext_RendersOutlineTreeIDs(t *testing.T) { func TestBuildContext_RendersOutlineStructurePreset(t *testing.T) { out := buildContext(PromptData{ OutlineStructure: "웹소설: 권 > 화 > 씬 (예: 1권 > 1화 > 씬 1)", - }) + }, "") for _, want := range []string{"## 아웃라인 구조 프리셋", "웹소설: 권 > 화 > 씬", "계층과 라벨 예시"} { if !strings.Contains(out, want) { t.Fatalf("outline structure context missing %q in:\n%s", want, out) @@ -138,7 +194,7 @@ func TestBuildContext_RendersOutlineStructurePreset(t *testing.T) { } func TestBuildSystem_ForbidsInventingIDs(t *testing.T) { - s := buildSystem() + s := buildSystem("") if !strings.Contains(s, "지어내지 마") || !strings.Contains(s, "node_id") { t.Fatal("system prompt missing id-discipline guidance") } @@ -149,7 +205,7 @@ func TestBuildContext_EntityShowsKind(t *testing.T) { ID: "e1", Kind: "concept", Name: "빛의 맹약", Role: "마법", Attributes: map[string]string{"효과": "거짓을 드러낸다", "비용": "기억 한 조각"}, }}} - out := buildContext(d) + out := buildContext(d, "") for _, want := range []string{"## 세계관 요소·관계", "[e1] (개념) 빛의 맹약 / 마법", "비용:기억 한 조각", "효과:거짓을 드러낸다"} { if !strings.Contains(out, want) { t.Fatalf("entity context missing %q:\n%s", want, out) @@ -158,7 +214,7 @@ func TestBuildContext_EntityShowsKind(t *testing.T) { } func TestBuildSystem_MentionsEntityOps(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"create_entity", "create_relationship", "from_ref", "attributes", "아이템", "스킬", "마법", "능력"} { if !strings.Contains(s, want) { t.Fatalf("buildSystem missing %q", want) @@ -167,7 +223,7 @@ func TestBuildSystem_MentionsEntityOps(t *testing.T) { } func TestBuildSystem_MentionsSceneAndPair(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"create_scene", "create_outline_node", "set_scene_text", "현재 씬", "parent_node_ref", "node_ref", "inverse_label"} { if !strings.Contains(s, want) { t.Fatalf("buildSystem missing %q", want) @@ -176,7 +232,7 @@ func TestBuildSystem_MentionsSceneAndPair(t *testing.T) { } func TestBuildSystem_MentionsFactBookRules(t *testing.T) { - s := buildSystem() + s := buildSystem("") for _, want := range []string{"create_fact_card", "출처 URL", "status", "팩트 자료집"} { if !strings.Contains(s, want) { t.Fatalf("buildSystem missing %q", want) @@ -196,7 +252,7 @@ func TestBuildContext_RendersFactBook(t *testing.T) { Title: "Met Police", }}, }}, - }) + }, "") for _, want := range []string{"## 팩트 자료집", "[f1]", "verified", "https://www.met.police.uk/"} { if !strings.Contains(out, want) { t.Fatalf("context missing %q in:\n%s", want, out) diff --git a/engine/internal/companion/query.go b/engine/internal/companion/query.go index 7f1a6fa..f5ab71c 100644 --- a/engine/internal/companion/query.go +++ b/engine/internal/companion/query.go @@ -41,30 +41,30 @@ func ParseQuery(full string) (QueryRequest, bool, error) { } // runQueries executes each read tool, returning a human-readable result block. -func (s *Service) runQueries(ctx context.Context, projectID string, qs []Query) string { +func (s *Service) runQueries(ctx context.Context, projectID string, qs []Query, lang string) string { var b strings.Builder - b.WriteString("## 조회 결과\n") + b.WriteString(pickLang(lang, "## 조회 결과\n", "## Query Results\n", "## 照会結果\n")) for _, q := range qs { b.WriteString("### " + q.Tool + "\n") - b.WriteString(s.runOneQuery(ctx, projectID, q)) + b.WriteString(s.runOneQuery(ctx, projectID, q, lang)) b.WriteString("\n") } return strings.TrimSpace(b.String()) } -func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query) string { +func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query, lang string) string { switch q.Tool { case "search_entities": ents, err := s.entities.Search(ctx, projectID, q.Args["query"], 20) if err != nil { - return "(오류: " + err.Error() + ")" + return pickLang(lang, "(오류: ", "(error: ", "(エラー: ") + err.Error() + ")" } if len(ents) == 0 { - return "(결과 없음)" + return pickLang(lang, "(결과 없음)", "(no results)", "(結果なし)") } var sb strings.Builder for _, e := range ents { - sb.WriteString(fmt.Sprintf("- [%s] (%s) %s", e.ID, kindLabel(e.Kind), e.Name)) + sb.WriteString(fmt.Sprintf("- [%s] (%s) %s", e.ID, kindLabel(e.Kind, lang), e.Name)) if e.Role != "" { sb.WriteString(" / " + e.Role) } @@ -77,35 +77,35 @@ func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query) st case "get_scene_text": id := q.Args["node_id"] if id == "" { - return "(오류: node_id 필요)" + return pickLang(lang, "(오류: node_id 필요)", "(error: node_id required)", "(エラー: node_id が必要)") } n, err := s.nodes.Get(ctx, id) if err != nil { - return "(오류: " + err.Error() + ")" + return pickLang(lang, "(오류: ", "(error: ", "(エラー: ") + err.Error() + ")" } txt := trimRunesLocal(plainTextFromDoc(n.ContentDoc), sceneTextMaxRunes) if txt == "" { - return "(본문 없음)" + return pickLang(lang, "(본문 없음)", "(no scene text)", "(本文なし)") } return txt case "search_manuscript": query := strings.TrimSpace(q.Args["query"]) if query == "" { - return "(오류: query 필요)" + return pickLang(lang, "(오류: query 필요)", "(error: query required)", "(エラー: query が必要)") } if s.manuscript == nil { - return "(오류: 본문 검색을 사용할 수 없음)" + return pickLang(lang, "(오류: 본문 검색을 사용할 수 없음)", "(error: manuscript search unavailable)", "(エラー: 本文検索が利用不可)") } hits, err := s.manuscript.Query(ctx, projectID, query, parseQueryLimit(q.Args["limit"], 5, 20)) if err != nil { - return "(오류: " + err.Error() + ")" + return pickLang(lang, "(오류: ", "(error: ", "(エラー: ") + err.Error() + ")" } if len(hits) == 0 { - return "(검색 결과 없음)" + return pickLang(lang, "(검색 결과 없음)", "(no search hits)", "(検索結果なし)") } var sb strings.Builder for _, h := range hits { - label := h.Breadcrumb + label := node.DisplayBreadcrumb(h.Breadcrumb, lang) if label == "" { label = h.NodeID } @@ -119,7 +119,7 @@ func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query) st case "list_scenes": all, err := s.nodes.ListByProject(ctx, projectID) if err != nil { - return "(오류: " + err.Error() + ")" + return pickLang(lang, "(오류: ", "(error: ", "(エラー: ") + err.Error() + ")" } byID := map[string]node.Node{} for _, n := range all { @@ -130,10 +130,10 @@ func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query) st if n.Kind != "leaf" { continue } - sb.WriteString("- [" + n.ID + "] " + node.BreadcrumbLabel(byID, n) + "\n") + sb.WriteString("- [" + n.ID + "] " + node.DisplayBreadcrumb(node.BreadcrumbLabel(byID, n), lang) + "\n") } if sb.Len() == 0 { - return "(씬 없음)" + return pickLang(lang, "(씬 없음)", "(no scenes)", "(シーンなし)") } return strings.TrimRight(sb.String(), "\n") case "list_beats": @@ -144,13 +144,13 @@ func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query) st } else if tid := q.Args["thread_id"]; tid != "" { bs, err = s.beats.ListByThread(ctx, tid) } else { - return "(오류: node_id 또는 thread_id 필요)" + return pickLang(lang, "(오류: node_id 또는 thread_id 필요)", "(error: node_id or thread_id required)", "(エラー: node_id または thread_id が必要)") } if err != nil { - return "(오류: " + err.Error() + ")" + return pickLang(lang, "(오류: ", "(error: ", "(エラー: ") + err.Error() + ")" } if len(bs) == 0 { - return "(비트 없음)" + return pickLang(lang, "(비트 없음)", "(no beats)", "(ビートなし)") } var sb strings.Builder for _, bt := range bs { @@ -164,11 +164,11 @@ func (s *Service) runOneQuery(ctx context.Context, projectID string, q Query) st case "recall_memory": hits := s.Recall(projectID, q.Args["query"], recallLimit) if len(hits) == 0 { - return "(기억 없음)" + return pickLang(lang, "(기억 없음)", "(no memories)", "(記憶なし)") } return "- " + strings.Join(hits, "\n- ") default: - return "(오류: 알 수 없는 도구 " + q.Tool + ")" + return pickLang(lang, "(오류: 알 수 없는 도구 ", "(error: unknown tool ", "(エラー: 不明なツール ") + q.Tool + ")" } } diff --git a/engine/internal/companion/query_test.go b/engine/internal/companion/query_test.go index 1c34887..2d5d6ea 100644 --- a/engine/internal/companion/query_test.go +++ b/engine/internal/companion/query_test.go @@ -54,7 +54,7 @@ func TestRunQueries(t *testing.T) { out := svc.runQueries(ctx, projectID, []Query{ {Tool: "search_entities", Args: map[string]string{"query": "하나"}}, {Tool: "bogus"}, - }) + }, "") if !strings.Contains(out, "하나") { t.Fatalf("expected entity name in output: %s", out) } @@ -76,7 +76,7 @@ func TestRunQueries_ListScenes(t *testing.T) { } firstSceneID := *p.LastOpenedNodeID - out := svc.runOneQuery(ctx, projectID, Query{Tool: "list_scenes"}) + out := svc.runOneQuery(ctx, projectID, Query{Tool: "list_scenes"}, "") if !strings.Contains(out, firstSceneID) { t.Fatalf("list_scenes should list the first scene id %q:\n%s", firstSceneID, out) } @@ -100,12 +100,12 @@ func TestRunQueries_SearchManuscript(t *testing.T) { out := svc.runOneQuery(ctx, projectID, Query{Tool: "search_manuscript", Args: map[string]string{ "query": "진홍빛", - }}) + }}, "") if !strings.Contains(out, *p.LastOpenedNodeID) || !strings.Contains(out, "진홍빛") { t.Fatalf("search_manuscript output missing node id or snippet:\n%s", out) } - out = svc.runOneQuery(ctx, projectID, Query{Tool: "search_manuscript", Args: map[string]string{}}) + out = svc.runOneQuery(ctx, projectID, Query{Tool: "search_manuscript", Args: map[string]string{}}, "") if !strings.Contains(out, "query 필요") { t.Fatalf("empty query should return parameter error, got:\n%s", out) } diff --git a/engine/internal/companion/references.go b/engine/internal/companion/references.go index 88b0327..cc6b65e 100644 --- a/engine/internal/companion/references.go +++ b/engine/internal/companion/references.go @@ -316,7 +316,7 @@ func deterministicReferenceSummary(ref Reference) string { return "" } return fmt.Sprintf("%s · %s · 원문 %d자 중 발췌 요약\n%s", - purposeLabel(ref.Purpose), ref.Title, ref.CharCount, body) + purposeLabel(ref.Purpose, ""), ref.Title, ref.CharCount, body) } func referencePromptText(ref Reference) string { diff --git a/engine/internal/companion/runner.go b/engine/internal/companion/runner.go index 5b55bff..55bfa0e 100644 --- a/engine/internal/companion/runner.go +++ b/engine/internal/companion/runner.go @@ -106,7 +106,31 @@ type reasoningPayload struct { // friendlyToolLabel maps a tool name to a human-readable status shown while the // companion is working, so the user sees what the AI is doing. -func friendlyToolLabel(name string) string { +func friendlyToolLabel(name, lang string) string { + if isJapanese(lang) { + switch name { + case "web_search": + return "ウェブ検索中…" + case "web_fetch": + return "ウェブページを読み込み中…" + case "linetta_apply_ops": + return "作品設定を反映中…" + default: + return "ツール実行中: " + name + } + } + if isEnglish(lang) { + switch name { + case "web_search": + return "Searching the web…" + case "web_fetch": + return "Reading a web page…" + case "linetta_apply_ops": + return "Applying story changes…" + default: + return "Running tool: " + name + } + } switch name { case "web_search": return "웹 검색 중…" @@ -119,6 +143,30 @@ func friendlyToolLabel(name string) string { } } +// appliedStatusText is the transient status shown after linetta_apply_ops +// finishes during a run. +func appliedStatusText(lang string) string { + if isEnglish(lang) { + return "Story state updated" + } + if isJapanese(lang) { + return "作品設定を更新しました" + } + return "작품 설정을 갱신했습니다" +} + +// cancelledMessage is the assistant history entry recorded when a run is +// cancelled by the user. +func cancelledMessage(lang string) string { + if isEnglish(lang) { + return "Request stopped." + } + if isJapanese(lang) { + return "リクエストを中止しました。" + } + return "요청을 중지했습니다." +} + // Runner manages companion run lifecycle + cancellation. type Runner struct { svc *Service @@ -130,7 +178,7 @@ func newRunner(svc *Service) *Runner { return &Runner{svc: svc, active: map[string]context.CancelFunc{}} } -func (r *Runner) start(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, outlineStructure string, requestIntent RequestIntent, requestedScope string, images []ImageAttachment, now func() int64) (string, error) { +func (r *Runner) start(ctx context.Context, projectID, nodeID, text string, selection ai.ContextSelection, outlineStructure string, requestIntent RequestIntent, requestedScope string, images []ImageAttachment, language string, now func() int64) (string, error) { sess, err := r.svc.sessions.EnsureWorker(projectID) if err != nil { return "", err @@ -178,8 +226,8 @@ func (r *Runner) start(ctx context.Context, projectID, nodeID, text string, sele // Build the message list: system + context + history (history already // includes the just-appended user turn as its last item). - msgs := []llm.ChatMessage{{Role: "system", Content: buildSystem()}} - if cctx := buildContext(data); cctx != "" { + msgs := []llm.ChatMessage{{Role: "system", Content: buildSystem(language)}} + if cctx := buildContext(data, language); cctx != "" { msgs = append(msgs, llm.ChatMessage{Role: "user", Content: cctx}) } if r.svc.history != nil { @@ -229,7 +277,7 @@ func (r *Runner) start(ctx context.Context, projectID, nodeID, text string, sele return "", err } } - go r.run(runCtx, runID, projectID, nodeID, scope, path, text, msgs, client, intent, now) + go r.run(runCtx, runID, projectID, nodeID, scope, path, text, msgs, client, intent, language, now) return runID, nil } @@ -286,7 +334,7 @@ func (r *Runner) cancel(runID string) error { const maxQueryRounds = 3 const applyOpsToolName = "linetta_apply_ops" -func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, userText string, msgs []llm.ChatMessage, client llm.Client, intent companionIntent, now func() int64) { +func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, userText string, msgs []llm.ChatMessage, client llm.Client, intent companionIntent, language string, now func() int64) { defer r.finish(runID) intentName := string(intent.Kind) @@ -308,7 +356,7 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, if evt.ToolName == applyOpsToolName { applyOpsAttempted = true } - _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: friendlyToolLabel(evt.ToolName)}) + _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: friendlyToolLabel(evt.ToolName, language)}) case agentloop.EventAfterTool: if evt.ToolName == applyOpsToolName && !evt.ToolIsError { applyOpsSucceeded = true @@ -318,7 +366,7 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, sceneTextSucceeded = true } } - _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: "작품 설정을 갱신했습니다"}) + _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: appliedStatusText(language)}) } } })) @@ -344,9 +392,9 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, if forcedApplyOps && !applyOpsSucceeded && !applyOpsCorrectionUsed { applyOpsCorrectionUsed = true _ = r.svc.notify.Notify("companion.reset", resetPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: ""}) - _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: friendlyToolLabel(applyOpsToolName)}) + _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: friendlyToolLabel(applyOpsToolName, language)}) dedup = streamdedup.New() - return directApplyCorrectionPrompt(userText, intent), nil + return directApplyCorrectionPrompt(userText, intent, language), nil } if queryRounds >= maxQueryRounds-1 { return "", nil @@ -354,16 +402,16 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, full := lastResp.Message.Content if qr, present, qerr := ParseQuery(full); present && qerr == nil { _ = r.svc.notify.Notify("companion.reset", resetPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: ""}) - _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: querySummary(qr)}) + _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: querySummary(qr, language)}) queryRounds++ dedup = streamdedup.New() - return r.svc.runQueries(ctx, projectID, qr.Queries), nil + return r.svc.runQueries(ctx, projectID, qr.Queries, language), nil } return "", nil }, }) if ctx.Err() != nil { - r.recordAssistantHistory(runID, projectID, nodeID, scope, intent, HistoryStatusCancelled, "요청을 중지했습니다.", now()) + r.recordAssistantHistory(runID, projectID, nodeID, scope, intent, HistoryStatusCancelled, cancelledMessage(language), now()) _ = r.svc.notify.Notify("companion.cancelled", cancelledPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName}) return } @@ -377,7 +425,7 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, full = dedup.Final() } if forcedApplyOps && !applyOpsSucceeded { - if result, ok := r.applyDirectProposalFallback(ctx, runID, projectID, nodeID, scope, userText, full, intent, now); ok { + if result, ok := r.applyDirectProposalFallback(ctx, runID, projectID, nodeID, scope, userText, full, intent, language, now); ok { applyOpsFallbackApplied = true applyOpsSucceeded = true lastApplyOpsResult = result @@ -387,21 +435,21 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, } } if applyOpsAttempted && !applyOpsSucceeded && strings.TrimSpace(full) == "" { - msg := applyOpsFailedMessage() + msg := applyOpsFailedMessage(language) r.recordAssistantHistory(runID, projectID, nodeID, scope, intent, HistoryStatusFailed, msg, now()) _ = r.svc.notify.Notify("companion.error", errorPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Message: msg}) return } if intent.RequiresSceneText() && !sceneTextSucceeded { - msg := sceneTextApplyFailedMessage() + msg := sceneTextApplyFailedMessage(language) r.recordAssistantHistory(runID, projectID, nodeID, scope, intent, HistoryStatusFailed, msg, now()) _ = r.svc.notify.Notify("companion.error", errorPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Message: msg}) return } if intent.RequiresSceneText() && sceneTextSucceeded { - full = sceneTextApplySuccessMessage(lastApplyOpsResult) + full = sceneTextApplySuccessMessage(lastApplyOpsResult, language) } else if applyOpsFallbackApplied { - full = applyOpsFallbackSuccessMessage(lastApplyOpsResult) + full = applyOpsFallbackSuccessMessage(lastApplyOpsResult, language) } assistantAt := now() @@ -441,7 +489,7 @@ func (r *Runner) run(ctx context.Context, runID, projectID, nodeID, scope, path, _ = r.svc.notify.Notify("companion.done", donePayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, FullText: full}) } -func (r *Runner) applyDirectProposalFallback(ctx context.Context, runID, projectID, nodeID, scope, userText, full string, intent companionIntent, now func() int64) (ApplyOpsResult, bool) { +func (r *Runner) applyDirectProposalFallback(ctx context.Context, runID, projectID, nodeID, scope, userText, full string, intent companionIntent, language string, now func() int64) (ApplyOpsResult, bool) { prop, present, err := ParseProposal(full) if !present || err != nil { return ApplyOpsResult{}, false @@ -450,7 +498,7 @@ func (r *Runner) applyDirectProposalFallback(ctx context.Context, runID, project return ApplyOpsResult{}, false } intentName := string(intent.Kind) - _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: friendlyToolLabel(applyOpsToolName)}) + _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: friendlyToolLabel(applyOpsToolName, language)}) result := r.svc.ApplyOps(ctx, projectID, nodeID, prop, now) if result.Applied > 0 { _ = r.svc.notify.Notify("companion.applied", appliedPayload{ @@ -467,7 +515,7 @@ func (r *Runner) applyDirectProposalFallback(ctx context.Context, runID, project if result.Applied == 0 || result.isError() { return result, false } - _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: "작품 설정을 갱신했습니다"}) + _ = r.svc.notify.Notify("companion.thinking", thinkingPayload{RunID: runID, ProjectID: projectID, NodeID: nodeID, Scope: scope, Intent: intentName, Text: appliedStatusText(language)}) return result, true } @@ -492,11 +540,17 @@ func (r *Runner) recordAssistantHistory(runID, projectID, nodeID, scope string, } // querySummary returns a short "조회 중: toolA, toolB" status string. -func querySummary(qr QueryRequest) string { +func querySummary(qr QueryRequest, lang string) string { names := make([]string, 0, len(qr.Queries)) for _, q := range qr.Queries { names = append(names, q.Tool) } + if isEnglish(lang) { + return "Looking up: " + strings.Join(names, ", ") + } + if isJapanese(lang) { + return "照会中: " + strings.Join(names, ", ") + } return "조회 중: " + strings.Join(names, ", ") } @@ -528,7 +582,59 @@ func applyOpsResultHasSceneTextChange(result ApplyOpsResult) bool { return false } -func sceneTextApplySuccessMessage(result ApplyOpsResult) string { +func sceneTextApplySuccessMessage(result ApplyOpsResult, lang string) string { + if isJapanese(lang) { + if len(result.ChangedNodes) == 0 { + return "現在のシーン本文を反映しました。" + } + change := result.ChangedNodes[0] + var b strings.Builder + if change.CharCount > 0 { + fmt.Fprintf(&b, "現在のシーン本文を反映しました。(%d字)", change.CharCount) + } else { + b.WriteString("現在のシーン本文を反映しました。") + } + b.WriteString("\n\n作業の流れ\n") + if strings.TrimSpace(result.Summary) != "" { + b.WriteString("- リクエスト処理: ") + b.WriteString(strings.TrimSpace(result.Summary)) + b.WriteString("\n") + } + b.WriteString("- 現在のシーンの文脈とご指示に基づいて本文を執筆しました。\n") + b.WriteString("- 執筆した原稿を現在のシーン本文として直接適用しました。") + if preview := sceneTextPreviewForMessage(change.TextPreview); preview != "" { + b.WriteString("\n- 冒頭: \"") + b.WriteString(preview) + b.WriteString("\"") + } + return b.String() + } + if isEnglish(lang) { + if len(result.ChangedNodes) == 0 { + return "Applied the current scene text." + } + change := result.ChangedNodes[0] + var b strings.Builder + if change.CharCount > 0 { + fmt.Fprintf(&b, "Applied the current scene text. (%d chars)", change.CharCount) + } else { + b.WriteString("Applied the current scene text.") + } + b.WriteString("\n\nWork log\n") + if strings.TrimSpace(result.Summary) != "" { + b.WriteString("- Request: ") + b.WriteString(strings.TrimSpace(result.Summary)) + b.WriteString("\n") + } + b.WriteString("- Wrote the prose from the current scene's context and your instructions.\n") + b.WriteString("- Applied the draft directly as the current scene text.") + if preview := sceneTextPreviewForMessage(change.TextPreview); preview != "" { + b.WriteString("\n- Opening: \"") + b.WriteString(preview) + b.WriteString("\"") + } + return b.String() + } if len(result.ChangedNodes) == 0 { return "현재 씬 본문을 반영했습니다." } @@ -555,8 +661,40 @@ func sceneTextApplySuccessMessage(result ApplyOpsResult) string { return b.String() } -func applyOpsFallbackSuccessMessage(result ApplyOpsResult) string { +func applyOpsFallbackSuccessMessage(result ApplyOpsResult, lang string) string { count := result.Applied + if isJapanese(lang) { + if count <= 0 { + return "作品の状態を反映しました。" + } + var b strings.Builder + if count == 1 { + b.WriteString("作品の状態を反映しました。") + } else { + fmt.Fprintf(&b, "作品の状態に %d 件の変更を反映しました。", count) + } + if strings.TrimSpace(result.Summary) != "" { + b.WriteString("\n\n作業の流れ\n- リクエスト処理: ") + b.WriteString(strings.TrimSpace(result.Summary)) + } + return b.String() + } + if isEnglish(lang) { + if count <= 0 { + return "Applied the story state changes." + } + var b strings.Builder + if count == 1 { + b.WriteString("Applied the story state changes.") + } else { + fmt.Fprintf(&b, "Applied %d changes to the story state.", count) + } + if strings.TrimSpace(result.Summary) != "" { + b.WriteString("\n\nWork log\n- Request: ") + b.WriteString(strings.TrimSpace(result.Summary)) + } + return b.String() + } if count <= 0 { return "작품 상태를 반영했습니다." } @@ -578,16 +716,66 @@ func sceneTextPreviewForMessage(preview string) string { return trimRunesLocal(preview, 80) } -func sceneTextApplyFailedMessage() string { +func sceneTextApplyFailedMessage(lang string) string { + if isEnglish(lang) { + return "No text change was produced. Try again or check the current scene." + } + if isJapanese(lang) { + return "本文の変更が作成されませんでした。再試行するか、現在のシーンを確認してください。" + } return "본문 변경이 만들어지지 않았습니다. 다시 시도하거나 현재 씬을 확인해주세요." } -func applyOpsFailedMessage() string { +func applyOpsFailedMessage(lang string) string { + if isEnglish(lang) { + return "Could not apply the story changes. Please try again." + } + if isJapanese(lang) { + return "作品の変更を適用できませんでした。もう一度お試しください。" + } return "작품 변경을 적용하지 못했습니다. 다시 시도해주세요." } -func directApplyCorrectionPrompt(userText string, intent companionIntent) string { +func directApplyCorrectionPrompt(userText string, intent companionIntent, lang string) string { userText = strings.TrimSpace(userText) + if isJapanese(lang) { + if userText == "" { + userText = "(原文なし)" + } + if intent.RequiresSceneText() { + return "直前のユーザーリクエストは説明や提案ではなく、実際の作品状態の変更リクエストです。" + + "現在のシーン本文の執筆/修正/確定を求めているため、追加の質問や選択肢で終わらせないでください。" + + "すでに提供されている現在のシーン、前後の流れ、ユーザーの指示に基づいてすぐ読めるシーン本文を書き、" + + "必ず linetta_apply_ops の set_scene_text で実際のシーン原稿を置き換えてください。" + + "変更したと言葉だけで答えず、ツールを呼び出してください。\n\n" + + "ユーザーリクエスト: " + userText + } + return "直前のユーザーリクエストは説明や提案ではなく、実際の作品状態の変更リクエストです。" + + "変更したと言葉だけで答えず、linetta_apply_ops を呼び出して作品状態に適用してください。" + + "現在のシーン本文の書き直し/修正/拡張/推敲のリクエストなら set_scene_text で実際のシーン原稿を置き換えてください。" + + "アウトライン/目次のリクエストなら create_outline_node または create_scene で左のアウトラインツリーを作り、必要に応じてそのノードに create_thread/add_beat を繋げてください。" + + "現在の情報だけで適用できない場合は適用せず、足りない情報を一文で質問してください。\n\n" + + "ユーザーリクエスト: " + userText + } + if isEnglish(lang) { + if userText == "" { + userText = "(no original text)" + } + if intent.RequiresSceneText() { + return "The user's last request is an actual story-state change request, not a question or proposal. " + + "It asks you to write/revise/finalize the current scene text, so do not end with more questions or options. " + + "Write readable scene prose from the current scene, surrounding flow, and the user's instructions, " + + "and you MUST replace the actual scene text via linetta_apply_ops set_scene_text. " + + "Do not merely claim you changed it — call the tool.\n\n" + + "User request: " + userText + } + return "The user's last request is an actual story-state change request, not a question or proposal. " + + "Do not merely claim you changed it — call linetta_apply_ops to apply it to the story state. " + + "If it asks to rewrite/revise/expand/polish the current scene text, replace the actual prose via set_scene_text. " + + "If it is an outline/table-of-contents request, build the left outline tree with create_outline_node or create_scene, attaching create_thread/add_beat to those nodes as needed. " + + "If you cannot apply it with the information at hand, do not apply — ask one sentence for the missing detail.\n\n" + + "User request: " + userText + } if userText == "" { userText = "(원문 없음)" } @@ -612,6 +800,15 @@ var companionStructureTerms = []string{ "장소", "씬", "장면", "개요", "요약", "기억", "설정", "세계관", "시놉시스", "아웃라인", "얼개", "구조", "챕터", "막", "파트", "에피소드", "회차", "본문", "원고", "문장", + // English equivalents (input is lower-cased before matching). + "storyline", "plot", "beat", "character", "relationship", "place", + "scene", "overview", "synopsis", "outline", "structure", "chapter", + "episode", "manuscript", "prose", "draft", "worldbuilding", "lore", + // Japanese equivalents. + "ストーリーライン", "プロット", "ビート", "キャラクター", "人物", + "関係", "場所", "シーン", "場面", "概要", "あらすじ", "記憶", "設定", + "世界観", "シノプシス", "アウトライン", "構成", "章", "パート", + "エピソード", "本文", "原稿", "文章", } var companionMutationTerms = []string{ @@ -620,15 +817,33 @@ var companionMutationTerms = []string{ "써", "짜", "구성", "잡아", "세워", "완성", "나눠", "나누", "쪼개", "분할", "세분", "구체화", "확장", "전개", "다듬", "고쳐", "재작성", "초기화", "비워", "채워", + // English equivalents. + "write", "add", "create", "make", "change", "update", "save", + "delete", "remove", "revise", "rewrite", "expand", "split", + "refine", "organize", "polish", "fill in", "clear", "edit", + // Japanese equivalents. + "修正", "追加", "作成", "作って", "変えて", "変更", "反映", "保存", + "入れて", "整理", "更新", "削除", "消して", "執筆", "書いて", + "完成", "分けて", "分割", "具体化", "拡張", "展開", "整えて", + "直して", "書き直", "初期化", "埋めて", } var companionEducationalTerms = []string{ "작성법", "방법", "어떻게", "가이드", + // English equivalents. + "how to", "how do", "how can", "guide", "method", + // Japanese equivalents. + "書き方", "方法", "どうやって", "ガイド", } var companionResearchTerms = []string{ "검색", "찾아", "조사", "웹", "web", "url", "링크", "자료", "최신", "레퍼런스", + // English equivalents. + "search", "find", "look up", "research", "link", "reference", + "latest", "source", + // Japanese equivalents. + "検索", "調べて", "調査", "リンク", "資料", "最新", "リファレンス", } var companionDirectApplyTerms = []string{ @@ -637,11 +852,23 @@ var companionDirectApplyTerms = []string{ "짜줘", "짜 줘", "구성해", "잡아줘", "잡아 줘", "세워줘", "세워 줘", "나눠줘", "나눠 줘", "쪼개줘", "쪼개 줘", "구체화해", "확장해", "전개해", "다듬어", "고쳐줘", "고쳐 줘", "재작성해", + // English direct-request markers. + "please", "go ahead", "do it", "apply it", "write it", "make it", + "add it", "save it", "update it", "just write", "just apply", + // Japanese direct-request markers. + "してください", "してくれ", "お願いします", "反映して", "保存して", + "追加して", "書いて", "作成して", "適用して", "作って", } var companionDiscussionTerms = []string{ "어때", "추천", "아이디어", "설명", "알려", "검토", "브레인스토밍", "가능할까", "괜찮을까", + // English equivalents. + "what do you think", "recommend", "suggestion", "idea", "explain", + "tell me", "review", "brainstorm", "what if", "thoughts", + // Japanese equivalents. + "どう思う", "おすすめ", "アイデア", "説明", "教えて", "レビュー", + "ブレインストーミング", "可能かな", "大丈夫かな", } func containsAny(s string, terms []string) bool { diff --git a/engine/internal/contextualedit/contextualedit.go b/engine/internal/contextualedit/contextualedit.go index 7ba0ac4..ba7fa68 100644 --- a/engine/internal/contextualedit/contextualedit.go +++ b/engine/internal/contextualedit/contextualedit.go @@ -32,6 +32,17 @@ var ( ErrInvalidPlan = errors.New("invalid contextual edit plan") ) +// langPick returns the string for the app language; Korean is the default. +func langPick(lang, ko, en, ja string) string { + if strings.HasPrefix(lang, "en") { + return en + } + if strings.HasPrefix(lang, "ja") { + return ja + } + return ko +} + type ResolveTargetInput struct { ProjectID string `json:"project_id"` EntityID string `json:"entity_id,omitempty"` @@ -59,6 +70,7 @@ type ChangeInput struct { OldTerms []string `json:"old_terms,omitempty"` NewTerms []string `json:"new_terms,omitempty"` ReviewOnly bool `json:"review_only,omitempty"` + Language string `json:"language,omitempty"` } type MetadataCandidate struct { @@ -109,6 +121,7 @@ type ConsistencyInput struct { OldTerms []string `json:"old_terms"` NewTerms []string `json:"new_terms,omitempty"` ChangedEntityIDs []string `json:"changed_entity_ids,omitempty"` + Language string `json:"language,omitempty"` } type ConsistencyIssue struct { @@ -242,7 +255,7 @@ func (s *Service) PlanContextChange(ctx context.Context, in ChangeInput) (Change ID: "entity:" + target.EntityIDs[0] + ":name", Kind: metadataKindEntityName, TargetID: target.EntityIDs[0], - Label: "자료집 이름", + Label: langPick(in.Language, "자료집 이름", "Dossier name", "資料集の名前"), Before: target.CanonicalName, After: newTerms[0], Selected: true, @@ -273,7 +286,7 @@ func (s *Service) PlanContextChange(ctx context.Context, in ChangeInput) (Change } plan.ReviewCandidates = review if len(plan.MetadataCandidates) == 0 && len(plan.ManuscriptPlans) == 0 && len(plan.ReviewCandidates) == 0 { - plan.Warnings = append(plan.Warnings, "변경 후보를 찾지 못했습니다.") + plan.Warnings = append(plan.Warnings, langPick(in.Language, "변경 후보를 찾지 못했습니다.", "No change candidates were found.", "変更候補が見つかりませんでした。")) } return plan, nil } @@ -345,7 +358,7 @@ func (s *Service) CheckAfterChange(ctx context.Context, in ConsistencyInput) (Co NodeID: n.ID, Breadcrumb: node.BreadcrumbLabel(byID, n), Snippet: snippetAround(plain, term), - Message: "원고에 이전 표현이 남아 있습니다.", + Message: langPick(in.Language, "원고에 이전 표현이 남아 있습니다.", "The old term still appears in the manuscript.", "原稿に以前の表現が残っています。"), }) } } @@ -360,7 +373,7 @@ func (s *Service) CheckAfterChange(ctx context.Context, in ConsistencyInput) (Co Severity: "warning", Kind: IssueMetadataStale, Snippet: snippetAround(text, term), - Message: "자료집 항목에 이전 표현이 남아 있습니다.", + Message: langPick(in.Language, "자료집 항목에 이전 표현이 남아 있습니다.", "The old term still appears in a dossier entry.", "資料集の項目に以前の表現が残っています。"), }) } } @@ -375,7 +388,7 @@ func (s *Service) CheckAfterChange(ctx context.Context, in ConsistencyInput) (Co Severity: "info", Kind: IssueReviewNeeded, Snippet: snippetAround(text, term), - Message: "팩트 카드가 변경 후에도 맞는지 확인이 필요합니다.", + Message: langPick(in.Language, "팩트 카드가 변경 후에도 맞는지 확인이 필요합니다.", "Check whether this fact card is still correct after the change.", "変更後もこのファクトカードが正しいか確認が必要です。"), }) } } diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 0f6c96d..502b653 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -41,7 +41,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/thread" ) -const DefaultVersion = "0.9.0" +const DefaultVersion = "0.9.1" // Options configures an embedded engine instance. type Options struct { diff --git a/engine/internal/node/display_label.go b/engine/internal/node/display_label.go new file mode 100644 index 0000000..a332753 --- /dev/null +++ b/engine/internal/node/display_label.go @@ -0,0 +1,76 @@ +package node + +import ( + "fmt" + "regexp" + "strings" +) + +// Node labels are stored canonically (Korean patterns like "1권", "1화", +// "씬 1" from the outline presets), and translated at display time. The +// desktop UI does this in displayNodeLabel (i18n.tsx); DisplayLabel is the +// engine-side equivalent so AI prompts show labels in the app language and +// the model echoes them back correctly. + +var ( + sceneLabelRe = regexp.MustCompile(`^(?:씬|(?i:scene)|シーン)\s*(\d+)$`) + chapterLabelRe = regexp.MustCompile(`^(?:(\d+)장|(?i:chapter)\s+(\d+)|第(\d+)章)$`) + partLabelRe = regexp.MustCompile(`^(?:(\d+)부|(?i:part)\s+(\d+)|第(\d+)部)$`) + webPartLabelRe = regexp.MustCompile(`^(?:(\d+)권|(?i:arc)\s+(\d+)|第(\d+)巻)$`) + webChapterLabelRe = regexp.MustCompile(`^(?:(\d+)화|(?i:episode)\s+(\d+)|第(\d+)話)$`) +) + +// DisplayLabel renders a canonical node label in the given app language +// ("en*"/"ja*"; anything else is Korean). Unrecognized labels pass through. +func DisplayLabel(label, lang string) string { + trimmed := strings.TrimSpace(label) + if m := sceneLabelRe.FindStringSubmatch(trimmed); m != nil { + return formatNumbered(m[1], lang, "씬 %s", "Scene %s", "シーン %s") + } + if n := firstGroup(chapterLabelRe, trimmed); n != "" { + return formatNumbered(n, lang, "%s장", "Chapter %s", "第%s章") + } + if n := firstGroup(partLabelRe, trimmed); n != "" { + return formatNumbered(n, lang, "%s부", "Part %s", "第%s部") + } + if n := firstGroup(webPartLabelRe, trimmed); n != "" { + return formatNumbered(n, lang, "%s권", "Arc %s", "第%s巻") + } + if n := firstGroup(webChapterLabelRe, trimmed); n != "" { + return formatNumbered(n, lang, "%s화", "Episode %s", "第%s話") + } + return label +} + +// DisplayBreadcrumb translates each " / "-separated segment of a breadcrumb. +func DisplayBreadcrumb(breadcrumb, lang string) string { + parts := strings.Split(breadcrumb, " / ") + for i, p := range parts { + parts[i] = DisplayLabel(p, lang) + } + return strings.Join(parts, " / ") +} + +func firstGroup(re *regexp.Regexp, s string) string { + m := re.FindStringSubmatch(s) + if m == nil { + return "" + } + for _, g := range m[1:] { + if g != "" { + return g + } + } + return "" +} + +func formatNumbered(n, lang, ko, en, ja string) string { + switch { + case strings.HasPrefix(lang, "en"): + return fmt.Sprintf(en, n) + case strings.HasPrefix(lang, "ja"): + return fmt.Sprintf(ja, n) + default: + return fmt.Sprintf(ko, n) + } +} diff --git a/engine/internal/node/display_label_test.go b/engine/internal/node/display_label_test.go new file mode 100644 index 0000000..86a53df --- /dev/null +++ b/engine/internal/node/display_label_test.go @@ -0,0 +1,42 @@ +package node + +import "testing" + +func TestDisplayLabel(t *testing.T) { + cases := []struct { + label, lang, want string + }{ + {"1권", "ja", "第1巻"}, + {"1화", "ja", "第1話"}, + {"씬 1", "ja", "シーン 1"}, + {"1장", "ja", "第1章"}, + {"1부", "ja", "第1部"}, + {"1권", "en", "Arc 1"}, + {"1화", "en", "Episode 1"}, + {"씬 3", "en", "Scene 3"}, + {"2장", "en", "Chapter 2"}, + {"2부", "en", "Part 2"}, + {"1권", "ko", "1권"}, + {"1권", "", "1권"}, + // reverse direction: stored English label shown in Korean + {"Scene 2", "ko", "씬 2"}, + {"Episode 4", "ja", "第4話"}, + // unrecognized labels pass through + {"프롤로그", "en", "프롤로그"}, + {"雪の花が降る夜", "en", "雪の花が降る夜"}, + } + for _, c := range cases { + if got := DisplayLabel(c.label, c.lang); got != c.want { + t.Errorf("DisplayLabel(%q, %q) = %q, want %q", c.label, c.lang, got, c.want) + } + } +} + +func TestDisplayBreadcrumb(t *testing.T) { + if got := DisplayBreadcrumb("1권 / 1화 / 씬 1", "ja"); got != "第1巻 / 第1話 / シーン 1" { + t.Errorf("breadcrumb ja = %q", got) + } + if got := DisplayBreadcrumb("1부 / 1장 / 씬 2", "en"); got != "Part 1 / Chapter 1 / Scene 2" { + t.Errorf("breadcrumb en = %q", got) + } +} diff --git a/engine/internal/rpc/handlers/companion.go b/engine/internal/rpc/handlers/companion.go index 6758ff2..496cef7 100644 --- a/engine/internal/rpc/handlers/companion.go +++ b/engine/internal/rpc/handlers/companion.go @@ -59,6 +59,7 @@ type companionHistoryParams struct { NodeID string `json:"node_id"` Scope string `json:"scope"` Limit int `json:"limit"` + Language string `json:"language"` } type companionMessage struct { @@ -107,7 +108,7 @@ func CompanionCompact(svc *companion.Service, now Clock) rpc.Handler { NodeID: p.NodeID, Scope: p.Scope, Limit: p.Limit, - }, func() int64 { return now() }) + }, p.Language, func() int64 { return now() }) if err != nil { return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} } diff --git a/engine/internal/summarizer/summarizer.go b/engine/internal/summarizer/summarizer.go index c913aa8..edeb788 100644 --- a/engine/internal/summarizer/summarizer.go +++ b/engine/internal/summarizer/summarizer.go @@ -21,8 +21,11 @@ const queueSize = 256 const minRunesForLLM = 60 const containerSummaryMaxRunes = 4000 const maxSummarizeDepth = 6 -const systemPrompt = "다음 한국어 본문을 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지." -const containerSystemPrompt = "다음은 한국어 소설의 하위 단위 요약들이다. 이 단위 전체를 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지." + +// Summaries run in the background with no UI-language signal, so the prompt +// asks the model to follow the manuscript's own language instead. +const systemPrompt = "다음 본문을 본문과 같은 언어로 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지. (Summarize the passage below in 3-5 sentences, in the same language as the passage. Preserve characters, places, and key events. Do not add new information.)" +const containerSystemPrompt = "다음은 소설의 하위 단위 요약들이다. 이 단위 전체를 요약들과 같은 언어로 3~5문장으로 요약하라. 등장인물·장소·핵심 사건은 반드시 보존하라. 새 정보 추가 금지. (The lines below are summaries of a fiction unit's children. Summarize the whole unit in 3-5 sentences, in the same language as those summaries. Preserve characters, places, and key events. Do not add new information.)" type Summarizer struct { nodes *node.Repo diff --git a/packaging/flathub/com.devlikebear.linetta.yml b/packaging/flathub/com.devlikebear.linetta.yml index 30eb94e..d2fb2eb 100644 --- a/packaging/flathub/com.devlikebear.linetta.yml +++ b/packaging/flathub/com.devlikebear.linetta.yml @@ -20,7 +20,7 @@ modules: sources: - type: git url: https://github.com/devlikebear/linetta.git - tag: v0.9.0 + tag: v0.9.1 commit: REPLACE_WITH_RELEASE_COMMIT build-commands: - cd apps/desktop && corepack enable