diff --git a/.agents/context/process-status.json b/.agents/context/process-status.json index ac4dbeda6..e9a3bef61 100644 --- a/.agents/context/process-status.json +++ b/.agents/context/process-status.json @@ -15,8 +15,29 @@ "issue_doc": "docs/progress/ (F0-baseline, F0-contract, F{1,2,3}-baseline-contract, F0-graft) + 00-PHASES.md", "github_issue": null, "started": "2026-06-08T00:00:00Z", - "last_updated": "2026-08-20T15:53:16+09:00", + "last_updated": "2026-08-23T00:00:00+09:00", "status": "in_progress", + "v022_thinking_separation_479": { + "issue": "https://github.com/nextain/naia-shell/issues/479", + "scope": "thinking을 최종 응답과 분리하고 모든 청크 경계에서 TTS 유입을 차단", + "requirements": "docs/requirements.md FR-CHAT-THINKING.1~2", + "scenario": "docs/user-scenarios.md UC-V022-THINKING-SEPARATION", + "status": "in_progress", + "p01_user_scenarios": "done", + "p02_test_scenarios": "done", + "p03_requirements": "done", + "p04_integration_test": "pending", + "p05_complete": "pending", + "last_updated": "2026-08-23T00:00:00+09:00" + }, + "v022_tts_text_normalization_480": { + "issue": 480, + "P01_user_scenario": "done", + "P02_test_coverage_map": "done", + "P03_requirements": "done", + "P04_implementation_tests": "pending", + "P05_done_handoff": "pending" + }, "startup_update_prompt_468": { "issue": "https://github.com/nextain/naia-shell/issues/468", "scope": "앱 시작 시 명시적 동의가 필요한 업데이트 팝업과 버전별 30일 알림 유예, 기존 배너 및 설정 수동 확인 보존", @@ -172,7 +193,12 @@ "sdlc_gates": { "_note": "각 게이트의 최상위 status/deliverable/ref = 기본 트랙(current_work.issue = naia-shell-transplant, F0~F3) 소유. 병행 트랙은 같은 게이트의 tracks 에만 기재한다(한 사실 두 곳 금지 — 기본 트랙 상태를 tracks 에 복사하지 않는다).", "P01": { "status": "done", "name": "user_scenarios", "deliverable": "docs/user-scenarios.md (UC1-14, S01~S71)", "ref": "완전성 13R 3연속 NONE", - "tracks": { + "tracks": { + "v022_chat_rich_markdown_483": { + "issue": 483, + "status": "implementation_validating", + "phases": { "P01": "done", "P02": "done", "P03": "done", "P04": "in_progress", "P05": "pending" } + }, "installer_crossplatform_377": { "status": "done", "deliverable": "docs/user-scenarios.md — 셸 feature 시나리오 표의 S-INSTALL 행 (#377, 2026-07-17)", "ref": "DONE 2026-07-17 — 적대 리뷰 R1~R8 (8라운드, 실측 근거 결함 전부 반영). P0 종료 조건이 '리뷰 2연속 클린' 에서 **'구현이 검증'** 으로 조정됨(루크 승인 2026-07-17, AI 임의 변경 아님): R5~R8 이 각각 직전 라운드의 개정에서 MAJOR 를 찾는 진자운동에 들어갔고 잔여 결함 계열(로그 정규식·줄 개수·glob 형태)은 P1 구현 + vitest golden + 실제 CI 가 더 싸고 확실하게 잡는다는 판단. 상세 = current_work.installer_crossplatform_377" }, "v020_signed_updater_recovery": { "status": "done", "deliverable": "docs/user-scenarios.md UC-V020-*", "ref": "Signed update, legacy feed, and failure-honesty journeys" } } diff --git a/docs/requirements.md b/docs/requirements.md index 14f99aa08..0e11fff59 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -254,6 +254,9 @@ localStorage `naia-config` 는 파일에서 하이드레이트되는 **순수 | **FR-INSTALL.4** | **Windows 설치 실측** — clean 상태에서 `tauri:build:bundle` → NSIS+MSI 산출 → **NSIS** 무인 설치(`/S`, currentUser, 관리자 불요) → **설치본 실행 파일로 기동 + 에이전트 핸드셰이크 확인**(e2e-tauri `TAURI_BINARY` env 오버라이드 신설 — 현행 debug 경로 하드코딩 해소). **판정은 FR-INSTALL.5 와 동형의 2조건(R6 개정)**: 핸드셰이크만으로는 *어느* node 로 떴는지 증명하지 못하는데, 하필 이 실측 머신은 정의상 빌드 머신이라 시스템 node 가 반드시 있어(빌드 전제조건) PATH 폴백이 성공하면 그대로 green 이 된다 — `node.exe` 동봉이 #377 의 출발점인데 Windows 번들 분기만 검증축 밖에 남는 셈. 따라서 ① 핸드셰이크 **AND** ② 설치본 로그(사용자 홈 아래 `.naia/logs/naia.log`)에서 `[Naia] node = ` 를 **포함하는 줄이 최소 2줄 AND 그 줄들이 전부** 설치본 resource_dir(= NSIS `$INSTDIR`) 하위일 것(**R8 — 개수 하한 필수**: 전칭은 0줄이면 공허참이라 그대로 두면 이 게이트가 조용히 green 이 되는데, Linux 와 달리 Windows 에는 그 공허참을 잡아 줄 mutation probe 가 없어 **여기가 Windows 번들 분기의 유일한 축**이다) — **FR-INSTALL.5 와 동일한 세션 스코프를 적용한다(R7 필수)**: 이 로그는 누적 파일이고 이 머신은 정의상 빌드 머신이라 `tauri dev`·e2e-tauri 가 남긴 시스템 node 줄이 이미 들어 있다 → 스코프 없이는 정상 설치본도 red. **마지막 `[Naia] === Session started ===` 이후 줄만** 판정한다. MSI 는 산출·실존 확인까지(WiX MSI = perMachine/관리자 승격이 표준 — per-user 무인 실측은 NSIS 담당). **순수 Windows(WSL 불요) 불변**: 빌드·설치·런타임 전 구간에 WSL/POSIX 셸 의존 금지 — 허용 외부 도구 = OS 기본 제공(curl·tar), tauri CLI 가 스스로 관리하는 번들러 도구(NSIS/WiX 자동 다운로드), **VS 부속 도구(vswhere — VS C++ 빌드도구가 이미 빌드 전제조건이라 신규 의존 아님)**까지 | S-INSTALL | 실 빌드 산출물(.exe/.msi 실존+크기) · NSIS 무인 설치 후 설치본 기동 스모크 = **기동+핸드셰이크 AND `[Naia] node = ` 포함 줄이 최소 2줄 AND 전부 `$INSTDIR` 하위**(R6/R8 — Windows 번들 분기 실행 증명. 판정 범위 = 마지막 `=== Session started ===` 포함 줄 이후. 그 외 dev 환경 가정 오염 금지) | | **FR-INSTALL.5** | **CI 3 OS 빌드+설치 증명** — `.github/workflows/build-installers.yml`: windows/ubuntu/macos-latest 매트릭스, **push/수동 트리거만**(fork PR 제외 — 보안), naia-agent(공개) sibling clone, cascade-loader 는 optional 경로(private repo — 시크릿 없이 skip+로그), 산출물 artifact 업로드. **ubuntu job 은 빌드에 더해 deb 설치 → xvfb 기동 스모크**(번들 node 실사용 증명 — linux 미실측 공백 해소). **스모크 성공 판정(R5 개정 — 2조건 AND)**: 설치본 바이너리를 xvfb 아래 기동 → **120초 내** 셸 로그(사용자 홈 아래 `.naia/logs/naia.log`)에 ① 마커 `[Naia] agent-core gRPC @` 출현(gRPC 준비 핸드셰이크 — 자식 stdout 의 `GRPC_LISTENING` 을 실제 수신한 **뒤에만** 방출되므로 node 스폰 실패 시 나올 수 없음) **AND** ② `[Naia] node = ` 를 **포함하는 줄이 최소 2줄**(FR-INSTALL.3 의 부팅당 2줄 — agent·BGM) **AND** 그 줄들의 경로가 **전부 설치본 resource_dir 하위** = green. **개수 하한이 AND 로 붙는 이유(R8)**: 전칭("전부 … 하위")은 대상이 0줄이면 **참**이라, 로그 접두사 드리프트나 폴백 클로저 안쪽 기록 같은 이유로 줄이 사라지면 ② 가 공허참으로 green 이 되고 ①(핸드셰이크)만 남는데 그건 R6 가 폐기한 상태 그대로다 — 하한을 못 박으면 공허참이 원리적으로 불가능해진다. **"정확히" 가 아니라 "최소" 인 이유(R8 자체 교정)**: `restart_agent` 가 `spawn_agent_core` 를 재호출하면 node 를 다시 해석해 **3번째 줄**이 나온다(실측) — 상한을 박으면 재시작이 일어난 실행에서 정상 설치본이 거짓 red 가 된다. 하한 + 전칭이면 공허참도 부분 누출도 잡으면서 오탐이 없다. 하나라도 불충족·프로세스 조기 종료 = red(**R6**: "하나라도 하위" 가 아니라 **전부** — 그래야 BGM 만 시스템 node 로 새어도 red 가 된다). **판정 범위 = 이번 부팅의 줄만(R7 필수 — 세션 스코프)**: `naia.log` 는 `append` 전용 **머신 단위 누적 파일**이라(절단·회전 코드 0건) 이전 부팅들의 시스템 node 줄이 그대로 남아 있다 — 스코프가 없으면 ② 는 **정상 설치본에서도 영구히 red** 이고, 같은 job 안에서 도는 mutation probe 가 스모크보다 먼저 실행되면 그 줄이 섞여 **실행 순서에 판정이 좌우**된다. 따라서 **파일의 마지막 `[Naia] === Session started ===` 이후 줄만** 대상으로 한다(이 구분자는 setup 최상단에서 방출되어 두 spawn 보다 항상 선행 — 실측). 로그 파일 삭제로 대신하지 않는다(빌드 머신의 사용자 로그를 파괴하므로). 창 생존만으론 번들 node 를 증명하지 못하므로(에이전트 스폰 실패해도 창은 뜸) 로그 관측 기준이며, `NAIA_MINIMAL` 미설정 기본 부팅에서 스폰이 무조건 일어나는 것이 전제(CI job env 에 해당 변수 부재). **"PATH 에서 node 제거" 에 의존하지 않는다(R5 — 구설계 폐기)**: unix 폴백은 PATH 와 무관하게 사용자 홈 아래 `.nvm/versions/node` 를 **직접 디렉토리 스캔**하므로 PATH 만 끊는 것은 폴백을 차단하지 못한다. 현 GitHub 러너 이미지는 그 디렉토리가 비어 있어(`nvm alias default system`) 우연히 통과하지만, 그러면 **판정력이 외부 러너 이미지에 위탁**되어 이미지가 바뀌는 날 조용히 무력화된다. 더구나 FR-INSTALL.3 이 번들 node 를 **최우선**으로 두므로 PATH 제거는 정상 경로에 애초에 아무 영향이 없다 — 그래서 관측 대상을 ②(실제 사용된 경로)로 바꾼다. **자기 검증(mutation probe)**: 번들 node 를 일부러 제거한 실행 1회가 **red 가 되는지** CI 에서 확인 — 폴백이 하나 늘어도 게이트가 조용히 통과하지 않음을 증명(FR-INSTALL.6 의 부정 케이스 정신과 동일). **mac = arm64 전용·미서명·미공증 정직 표기**(`macos-latest` = arm64 러너 → `process.arch`=arm64 → darwin-arm64 node + arm64 호스트 타깃 = **Apple Silicon 전용 산출물, Intel Mac 몫 없음**. Intel 은 `macos-*-intel` 라벨 추가가 필요하며 후속. 우클릭 열기 필요) — 서명/updater `.sig` 는 범위 밖(별도 결정) | S-INSTALL | CI 3 job 전부 green + artifact 실존 · ubuntu 설치+기동 스모크 green(마커 **AND** node 줄 최소 2줄 **AND** 그 경로가 전부 resource_dir 하위 — 세션 스코프 적용) + mutation probe red 확인 (mac 완료선 = **arm64** 빌드 성공, 실기기 설치는 미보유 정직 표기) | | **FR-INSTALL.6** | **산출물 검증 스크립트 1개** `scripts/verify-artifacts.mjs`(3 OS 공통, OS 분기=매트릭스 — **R5: 경로·파일명 확정**, 미지정 시 구현자 분기): 매트릭스 OS 행의 **`artifacts`(FR-INSTALL.1 — glob + minBytes)** 를 읽어 ① 각 glob 이 **정확히 1개 이상** 매치 ② 매치된 파일이 `minBytes` 이상 ③ SHA256 을 **stdout + `artifacts.sha256` 파일**로 기록(CI 는 이 파일을 artifact 로 함께 업로드 — 현 범위의 소비자는 사람의 사후 대조이며, 자동 비교는 하지 않음을 명시). `minBytes` 가 null 이면 **명확한 에러로 중단**(임계 미정 = red, 조용한 통과 금지). CI 각 job 말미 + 빌드 머신에서 동일 실행. **판정 로직은 주입 가능한 순수 함수로 분리(R8 — FR-INSTALL.2 의 conf 생성기와 같은 제약)**: `verifyArtifacts({ bundleDir, artifacts })` 로 번들 루트와 매트릭스 행을 **인자로 받고**, CLI 진입점은 이를 호출만 한다. 이 저장소의 동류 스크립트는 경로를 `import.meta.url` 기준으로 자기 고정하는 관례라(`check-build-contract.mjs` 선례), 그대로 두면 부정 케이스 테스트가 실 번들을 건드리거나 **판정 로직을 재구현**하는 수밖에 없다 — 재구현하면 실제 스크립트가 glob 0 매치를 건너뛰거나 크기 비교 부호를 뒤집어도 테스트는 **영원히 green** 이라 이 FR 의 목적("항상 통과 스크립트" 차단)이 정확히 무력화된다. 부정 테스트는 **이 함수를 import** 해 임시 디렉토리 픽스처(빈 디렉토리 / minBytes 미만 파일)로 구동한다 — 재구현 금지. **자기 검증 포함**: 부정(negative) 케이스 단위 테스트(산출물 부재/과소 크기 → red)로 "항상 통과 스크립트" 차단 | S-INSTALL | 검증 스크립트 실행(Windows 빌드 머신 + CI 3 OS) + **부정 케이스 단위 테스트** `scripts/__tests__/verify-artifacts.test.ts`(부재·과소 크기→red. 경로 근거 = FR-INSTALL.2 의 `src-tauri/**` vitest 미수집 실측) | +| **FR-CLI.1** | 설치 표면의 `naia` 명령은 기존 `naia-shell` 실행 파일을 가리키는 얇은 alias여야 하며 실행 파일·업데이터 식별자는 개명하지 않는다. gateway 모니터 명령은 별도 이름으로 이관한다. | UC-CLI-OPEN | 3 OS 설치 산출물 alias/PATH 검사와 gateway 참조 전수 검사 | +| **FR-CLI.2** | `naia `은 호출 cwd 기준 상대 경로와 절대 경로를 정규화하고, 실존하는 일반 파일만 `workspace-open-file-request`로 전달한다. 실행 중인 셸은 포커스 후 열고, 콜드 스타트는 UI 준비 뒤 연다. | UC-CLI-OPEN | Rust 단위 + 네이티브 실행 중/콜드 스타트 인수 테스트 | +| **FR-CLI.3** | CLI 파일 열기는 기존 workspace `openFile` API를 재사용하며 파일 내용이나 경로를 로그·URL에 복제하지 않는다. 잘못된 인자는 셸 기동을 막지 않는다. | UC-CLI-OPEN | 프론트 이벤트 결선 테스트 + 오류/디렉터리/미존재 경로 부정 테스트 | > NFR: **NFR-noWSL(불변)** — 빌드·설치·런타임 어느 구간에도 WSL 요구 금지(현행 0건을 요구사항으로 고정). · NFR-honesty — 미실측(mac 실기기)·미서명을 문서와 산출물 설명에 그대로 표기, "지원" 위장 금지. · 재현성 = "사람 기억에 의존하는 수동 단계 0". ⚠️ 범위 밖(별도 이슈로 후속): 코드 서명(win 인증서·mac 공증), updater `.sig` 생성/키, **updater endpoint stale**(base conf 가 폐기된 `nextain/naia-os` releases 를 가리킴 — 설치본 첫 실행 시 죽은 endpoint 조회, 후속 이슈로 교정), flatpak 경로, `WslSetupScreen` 죽은 레거시 삭제(기존 DEFER 유지). @@ -821,3 +824,26 @@ unrelated direct TRT service on port 8910. | **FR-WORKSPACE-MARKDOWN.1** | Selecting `.md` or `.markdown` in FileTree opens a GFM preview by default and retains the existing document-tab lifecycle. The user can switch between preview and source, and an oversized or unreadable document shows a bounded, retryable error instead of blocking the shell. | viewer component tests + Linux Chromium FileTree journey | | **FR-WORKSPACE-MARKDOWN.2** | Relative links and images resolve from the current document or Workspace root. Canonical file reads remain enforced by the Rust Workspace boundary; traversal, raw HTML, script, protocol-relative content, and dangerous URL schemes cannot execute or read outside the Workspace. HTTP(S) links use the explicit system opener. | resolver/security/image tests + Rust boundary tests | | **FR-WORKSPACE-MARKDOWN.3** | The preview exposes an article landmark, keyboard focus indication, semantic GFM tables/tasks/headings, descriptive external-link text, and honest missing-image status while preserving code, image, PDF, CSV, and log viewers. | accessibility component assertions + existing viewer regression suite | + +## v0.2.2 thinking/final response separation (#479) + +| ID | Normative requirement | Verification | +|---|---|---| +| **FR-CHAT-THINKING.1** | Structured thinking events and case-insensitive `` text tags are accumulated only in the message `thinking` field. Tag tokens may be split at any stream boundary; neither tokens nor enclosed text may enter visible final content, the sentence chunker, or the TTS pipeline. Text following the closing tag remains complete and ordered. | incremental filter unit tests over every tag boundary + ChatArea TTS integration regression | +| **FR-CHAT-THINKING.2** | Streaming and persisted thinking use a separate semantic disclosure that is closed by default. This display rule never changes the unconditional exclusion of thinking from speech. | ChatArea component assertions for streaming/persisted closed disclosure, final answer rendering, and keyboard-accessible summary | +## v0.2.2 LLM→TTS 발화 텍스트 정규화 단일화 (#480) + +- `FR-VOICE-TEXT.1`: 모든 TTS provider 경로는 `text-filter`의 공통 규칙과 locale별 확장 규칙을 거친 동일한 발화 문자열만 사용해야 한다. +- `FR-VOICE-TEXT.2`: 필터는 Markdown 표식만 제거해 본문을 보존하고, fenced code/Mermaid, URL, 제어 태그, 이모지·이모티콘과 장식 문자를 제거해야 한다. +- `FR-VOICE-TEXT.3`: inline code는 locale 레지스트리와 기본 fallback으로 처리하고, 정규화 결과가 비면 발화 요청을 생성하지 않아야 한다. +### FR-CHAT-MARKDOWN.1 — 안전한 채팅 Markdown + +assistant 채팅은 GFM을 렌더링하되 원시 HTML 및 스크립트를 실행하지 않아야 한다. + +### FR-CHAT-MARKDOWN.2 — 코드와 Mermaid + +fenced code는 언어·복사·접기·워크스페이스 전환을 제공하고, Mermaid는 워크스페이스와 공유하는 strict 렌더러를 사용하며 실패 시 원문을 보존해야 한다. + +### FR-PERMISSION-SHORTCUT.1 — 권한 팝업 공통 단축키 (#477) + +도구 권한 팝업은 실행 `Alt+Y`, 항상 실행 `Alt+A`, 취소 `Alt+N`을 하나의 공통 정의에서 표시·해석하고, macOS에서는 같은 Alt 키를 Option 기호로 표시한다. 팝업이 열린 동안에만 수정키가 정확히 일치하는 최초 keydown을 한 번 처리한다. diff --git a/docs/user-scenarios.md b/docs/user-scenarios.md index a51a338f1..7558b3cc5 100644 --- a/docs/user-scenarios.md +++ b/docs/user-scenarios.md @@ -193,6 +193,7 @@ foundation UC 카탈로그와 직교하는 셸 feature(S72 선례). 각 시나 | **S-DOC** (#ui-reorg) | 대량 작업문서를 **탭으로 유지·전환**(문서 탭바)해 "터미널에서 문서 찾기 어려움" 해소. 서브에이전트 클릭 시 그 에이전트 최근문서가 탭으로 surface. Ctrl+P QuickOpen 유지 | 표현(셸 UI) — `openDocs` 상태 + DocTabBar | T10(세션 클릭→문서 탭 surface) + 91 S3/S6(에디터 무회귀) | | **S-ASK** (#ui-reorg) | 터미널 출력의 파일경로 **클릭=문서뷰어에서 열기 / Alt+클릭=대화창에 AI 질의**. 문서 탭에도 AI 질의(✦) 버튼 | 표현(셸 UI) — Terminal link provider Alt 분기 + 기존 `naia:ask-ai` 재사용 | `Terminal.tsx` activate Alt 분기. ⚠️ xterm 링크 클릭=실 앱 | | **S-INSTALL** (#377, FR-INSTALL — 2026-07-17) | 사용자가 **설치 파일을 받아 자기 OS(Windows/Linux/macOS)에 설치하고 첫 실행**한다 — Windows 는 NSIS(사용자 권한, 관리자 불요, **WSL 불요**) + MSI(관리자 설치 — WiX 표준), Linux 는 deb/rpm/AppImage, macOS 는 app/dmg(**arm64(Apple Silicon) 전용** · 미서명 — 우클릭 열기). Node 런타임이 3 OS 모두 동봉되어 **Node 미설치 머신에서도 에이전트가 뜬다**. 개발자는 clean checkout 에서 **명령 1개**로 자기 OS 의 설치 파일을 재현 빌드한다(수동 파일 배치 0). 플랫폼 차이(타깃·동봉 리소스·설치자 설정·기대 산출물)는 **매트릭스 데이터 1곳**이 정의하고, 스크립트는 OS 별 분리 없이 1개 | 배포(설치·첫 부팅) — 매트릭스→생성 conf, OS 분기=데이터 | `scripts/__tests__/platform-matrix.test.ts`(매트릭스 스키마 + conf 생성 golden, 3 OS) [단위] · `check-build-contract.mjs` PASS [계약] · **Windows 실측: 실 NSIS 무인 설치(/S) → 설치본 기동 — 핸드셰이크 AND `[Naia] node = ` 포함 줄이 최소 2줄 AND 전부 `$INSTDIR` 하위**(2조건, FR-INSTALL.4 — 빌드 머신엔 시스템 node 가 있어 기동만으론 번들 분기가 증명 안 됨. 개수 단언은 공허참 차단)(e2e-tauri `TAURI_BINARY` 설치 경로 지정) · **Linux: CI ubuntu job 이 deb 설치 → xvfb 기동 스모크 — 마커 `[Naia] agent-core gRPC @` **AND** node 줄 최소 2줄 **AND** 그 경로가 전부 설치본 resource_dir 하위**(R5: "PATH 에서 node 제거" 는 폐기 — 폴백이 PATH 무관하게 nvm 디렉토리를 직접 스캔하므로 번들 node 를 증명하지 못함. mutation probe 로 red 도 확인) — **Windows·Linux 양쪽 모두 판정 범위 = 마지막 `=== Session started ===` 포함 줄 이후**(`naia.log` 는 누적 파일) · macOS 실빌드 = CI(`build-installers.yml`) · **산출물 검증 스크립트 `scripts/verify-artifacts.mjs` 실행(빌드 머신 + CI 3 OS) + 부정(negative) 케이스 단위 테스트**(FR-INSTALL.6). ⚠️ mac = **arm64 전용**(CI `macos-latest` = arm64 러너, Intel 산출물 미제공 — 후속) + 실기기 설치 실측 미보유(정직 표기: 이번 완료선 = arm64 CI 빌드 성공) | +| **UC-CLI-OPEN** ([#484](https://github.com/nextain/naia-shell/issues/484), FR-CLI.1~3) | 설치 사용자가 새 터미널에서 `naia`를 실행하면 Naia Shell이 열리거나 기존 창이 포커스된다. `naia `은 상대 경로를 호출 터미널 기준으로 해석해, 셸이 꺼져 있든 실행 중이든 같은 워크스페이스 에디터에 해당 파일을 연다. 존재하지 않는 경로와 디렉터리는 열지 않는다. | 설치 alias/PATH 스모크 · Rust 인자/경로 계약 테스트 · 실행 중/콜드 스타트 네이티브 인수 테스트 | > **S-INSTALL #411·#412 보강(2026-08-02):** paired Agent와 로컬 의존 프로젝트의 pnpm 버전이 > 서로 달라도 스테이징은 각 `package.json#packageManager` 선언을 Corepack으로 실행한다. @@ -882,3 +883,34 @@ P02 상태 매트릭스: clean install/list/restart/remove/list, legacy migratio | **UC-V021-MARKDOWN-BOUNDARY** | raw HTML/script와 `javascript:` 및 Workspace 밖 상대 경로는 실행·열기되지 않는다. 외부 HTTP(S) 링크는 외부 링크임을 알리고 시스템 opener를 명시적으로 호출하며, 누락 이미지·읽기 실패·5 MiB 초과 문서는 복구 가능한 오류로 표시된다. | resolver, opener, missing-image, load-limit and accessibility assertions | P02 상태 매트릭스: `.md`/`.markdown`, preview/source 전환, GFM 표·체크리스트·취소선·코드 펜스, 문서/루트 상대 링크, 로컬/누락 이미지, HTTP(S)/위험 URL, 경계 밖 traversal, raw HTML, 읽기 실패와 대용량 거부, 키보드 포커스를 각각 검증한다. + +### 2026-08-23 thinking/final 응답 분리 (#479) + +| Scenario | User-observable outcome | Coverage | +|---|---|---| +| **UC-V022-THINKING-SEPARATION** | 모델이 구조화된 thinking 청크 또는 스트림 경계에서 나뉜 `` 태그를 보내도, 사용자는 thinking을 기본으로 닫힌 별도 영역에서만 보고 일반 답변에는 최종 응답만 본다. thinking 표시 여부와 무관하게 음성은 최종 응답만 발화한다. | `thinking-stream-filter.test.ts`의 구조화/태그/모든 청크 경계 계약 + `ChatArea.test.tsx`의 저장·스트리밍 닫힘 UI 및 TTS 제외 통합 계약 | + +P02 상태 매트릭스: thinking 없음, 구조화된 thinking 진행, 한 청크 태그, 여는·닫는 태그의 모든 청크 경계, thinking 뒤 최종 응답, 닫히지 않은 thinking, 완료 후 저장 메시지, 기본 닫힘과 키보드로 펼침을 검증한다. 오류·빈 목록·좁은 폭은 새 화면이나 레이아웃을 만들지 않는 인라인 `
`의 기존 채팅 동작을 보존하는 것으로 확인한다. +## 2026-08-23 LLM→TTS 발화 텍스트 정규화 단일화 (#480) + +### UC-V022-TTS-TEXT-NORMALIZATION + +- 사용자가 어떤 언어와 음성 provider를 선택해도 동일한 공통 정규화 경계를 거친다. +- Markdown 본문과 자연스러운 다국어 문장·숫자·구두점은 보존한다. +- 코드 블록, Mermaid, URL, 이모지·이모티콘, 장식 문자와 제어 태그는 음성 요청에서 제외한다. +- inline code는 선택 언어의 레지스트리 규칙을 적용하며 backtick은 발화하지 않는다. +- 정규화 결과가 비면 브라우저·로컬·원격 어느 provider에도 발화 요청을 만들지 않는다. + +Test Coverage Map: `lib/tts/__tests__/text-filter.test.ts`가 공통/언어/fallback 규칙을 검증하고, `lib/tts/__tests__/sentence-pipeline.test.ts`가 실제 provider payload의 단일 정규화 경계를 검증한다. +## UC-V022-CHAT-RICH-MARKDOWN — 채팅 Markdown·코드·Mermaid + +- assistant 응답의 안전한 GFM을 읽기 좋은 형태로 표시한다. +- fenced code는 언어, 복사, 접기/펼치기, 워크스페이스 전환 기능을 제공한다. +- Mermaid는 strict 보안 모드로 렌더링하고 실패 시 원문으로 복구한다. +- 스트리밍 중 미완성 fence와 긴 코드가 대화 레이아웃을 깨뜨리지 않는다. + +## UC-V022-PERMISSION-SHORTCUTS — 권한 결정을 키보드로 선택 (#477) + +- 도구 권한 팝업이 열렸을 때 `Alt+Y`는 이번만 허용, `Alt+A`는 항상 허용, `Alt+N`은 거부를 정확히 한 번 실행한다. +- 버튼의 플랫폼별 단축키 표기와 실제 키 해석은 같은 정의를 사용한다. +- 팝업이 닫히면 리스너가 제거되며, 일반 입력과 추가 수정키 조합은 가로채지 않는다. diff --git a/packages/shell/package.json b/packages/shell/package.json index 43c9af67e..d2f913220 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,7 +1,7 @@ { "name": "naia-shell", "private": true, - "version": "0.2.1", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", @@ -29,8 +29,7 @@ "test:e2e:tauri:nva-prebaked": "node scripts/run-nva-prebaked-e2e.mjs", "test:e2e:tauri:voice-6g": "node scripts/run-voice-6g-e2e.mjs", "test:e2e:tauri:jeonju-course": "pnpm run build:e2e:tauri && node scripts/run-jeonju-course-e2e.mjs", - "test:e2e:tauri:discord-settings": "pnpm run build:e2e:tauri && node scripts/run-discord-settings-e2e.mjs", - + "test:e2e:tauri:discord-settings": "pnpm run build:e2e:tauri && node scripts/run-discord-settings-e2e.mjs", "test:e2e:tauri:radio": "node scripts/build-radio-queue-e2e.mjs && wdio run e2e-tauri/wdio.conf.radio-queue.ts", "test:e2e:tauri:nva": "wdio run e2e-tauri/wdio.conf.ts --spec e2e-tauri/specs/13-nva-capability.spec.ts", "test:e2e:tauri:nva-startup": "node e2e-tauri/nva-startup-capability.mjs", @@ -67,6 +66,7 @@ "@xterm/xterm": "^6.0.0", "ansi-to-html": "^0.7.2", "dompurify": "^3.3.3", + "highlight.js": "^11.12.0", "mermaid": "^11.13.0", "papaparse": "^5.5.3", "pdfjs-dist": "5.4.296", diff --git a/packages/shell/scripts/__tests__/platform-matrix.test.ts b/packages/shell/scripts/__tests__/platform-matrix.test.ts index b93ac2be4..11e2f1f57 100644 --- a/packages/shell/scripts/__tests__/platform-matrix.test.ts +++ b/packages/shell/scripts/__tests__/platform-matrix.test.ts @@ -202,6 +202,11 @@ describe("platform-matrix 스키마 (FR-INSTALL.1)", () => { expect(nsis).toContain("NSIS_HOOK_PREINSTALL"); expect(nsis).toContain('RMDir /r "$INSTDIR\\agent"'); expect(nsis).toContain('RMDir /r "$INSTDIR\\~"'); + expect(nsis).toContain("NSIS_HOOK_POSTINSTALL"); + expect(nsis).toContain("$LOCALAPPDATA\\Microsoft\\WindowsApps"); + expect(nsis).toContain("REM managed by Naia Shell installer"); + expect(nsis).toContain('"$INSTDIR\\naia-shell.exe" %*'); + expect(nsis).toContain("REM managed by naia-adk manage-discord-sessions"); expect(nsis).toContain("NSIS_HOOK_POSTUNINSTALL"); expect(nsis).toContain('RMDir /r "$INSTDIR"'); const wix = readFileSync( @@ -216,7 +221,7 @@ describe("platform-matrix 스키마 (FR-INSTALL.1)", () => { expect(wix).toContain('Directory="TARGETDIR"'); expect(wix).not.toContain('Directory="SystemFolder"'); expect(wix).toContain("WIX_UPGRADE_DETECTED"); - expect(wix).toContain('cd /D "[INSTALLDIR]"'); + expect(wix).toContain("cd /D "[INSTALLDIR]""); expect(wix).toContain( "for %D in (agent assets bgm-sidecar cascade-loader cascade-runtime herdr voxcpm2-runtime ~)", ); diff --git a/packages/shell/src-tauri/src/lib.rs b/packages/shell/src-tauri/src/lib.rs index e1f6a0198..24f23c9c0 100644 --- a/packages/shell/src-tauri/src/lib.rs +++ b/packages/shell/src-tauri/src/lib.rs @@ -21,6 +21,33 @@ use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_store::StoreExt; +const WORKSPACE_OPEN_FILE_EVENT: &str = "workspace-open-file-request"; + +fn resolve_cli_file(args: &[String], cwd: &std::path::Path) -> Option { + args.iter() + .skip(1) + .filter(|arg| !arg.starts_with('-') && !arg.starts_with("naia://")) + .find_map(|arg| { + let candidate = std::path::PathBuf::from(arg); + let candidate = if candidate.is_absolute() { + candidate + } else { + cwd.join(candidate) + }; + let canonical = dunce::canonicalize(candidate).ok()?; + canonical + .is_file() + .then(|| canonical.to_string_lossy().to_string()) + }) +} + +#[tauri::command] +fn get_startup_open_file() -> Option { + let args: Vec = std::env::args().collect(); + let cwd = std::env::current_dir().ok()?; + resolve_cli_file(&args, &cwd) +} + /// Cross-platform home directory: HOME (Unix) or USERPROFILE (Windows). pub(crate) fn home_dir() -> String { std::env::var("HOME") @@ -11134,11 +11161,14 @@ pub fn run() { let oauth_state = app .try_state::() .map(|state| state.oauth_state.clone()); - for arg in args { + for arg in &args { if arg.starts_with("naia://") { process_deep_link_url(&arg, app, oauth_state.as_ref(), "single-instance"); } } + if let Some(path) = resolve_cli_file(&args, std::path::Path::new(&_cwd)) { + let _ = app.emit(WORKSPACE_OPEN_FILE_EVENT, path); + } })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -11212,6 +11242,7 @@ pub fn run() { generate_oauth_state, read_local_binary, write_temp_text, + get_startup_open_file, discord_bot_token_available, discord_connection_status, discord_capture_bot_token, @@ -11776,6 +11807,61 @@ mod tests { std::fs::write(path, b"fixture").unwrap(); } + #[test] + fn cli_file_resolves_relative_to_invocation_cwd() { + let cwd = tempfile::tempdir().unwrap(); + let file = cwd.path().join("notes").join("hello.md"); + write_test_file(&file); + let args = vec!["naia-shell".to_string(), "notes/hello.md".to_string()]; + + assert_eq!( + resolve_cli_file(&args, cwd.path()), + Some( + dunce::canonicalize(file) + .unwrap() + .to_string_lossy() + .to_string() + ) + ); + } + + #[test] + fn cli_file_skips_flags_deep_links_missing_paths_and_directories() { + let cwd = tempfile::tempdir().unwrap(); + let file = cwd.path().join("actual file.txt"); + write_test_file(&file); + let args = vec![ + "naia-shell".to_string(), + "--verbose".to_string(), + "naia://auth/callback".to_string(), + "missing.txt".to_string(), + ".".to_string(), + "actual file.txt".to_string(), + ]; + + assert_eq!( + resolve_cli_file(&args, cwd.path()), + Some( + dunce::canonicalize(file) + .unwrap() + .to_string_lossy() + .to_string() + ) + ); + } + + #[test] + fn cli_file_returns_none_when_no_regular_file_exists() { + let cwd = tempfile::tempdir().unwrap(); + let args = vec![ + "naia-shell".to_string(), + cwd.path().to_string_lossy().to_string(), + "not-there.md".to_string(), + ]; + + assert_eq!(resolve_cli_file(&args, cwd.path()), None); + } + #[test] fn voxcpm2_payload_validation_reports_every_activation_contract_failure() { let runtime = tempfile::tempdir().unwrap(); diff --git a/packages/shell/src-tauri/windows/installer-hooks.nsh b/packages/shell/src-tauri/windows/installer-hooks.nsh index 429fa9fc5..b5c8cd226 100644 --- a/packages/shell/src-tauri/windows/installer-hooks.nsh +++ b/packages/shell/src-tauri/windows/installer-hooks.nsh @@ -14,6 +14,35 @@ RMDir /r "$INSTDIR\~" !macroend +; `naia` is the stable command-line product name while the installed binary +; deliberately remains naia-shell.exe for updater and shortcut compatibility. +; WindowsApps is already on the per-user PATH on supported Windows installs, +; so the alias does not require a process-wide PATH mutation or elevation. +!macro NSIS_HOOK_POSTINSTALL + StrCpy $0 "$LOCALAPPDATA\Microsoft\WindowsApps" + CreateDirectory "$0" + + ; The private Discord operator tooling used to own naia.cmd. Migrate only + ; that exact managed launcher; never overwrite an unrelated user command. + ${If} ${FileExists} "$0\naia.cmd" + FileOpen $1 "$0\naia.cmd" r + FileRead $1 $2 + FileRead $1 $3 + FileClose $1 + ${If} $3 == "REM managed by naia-adk manage-discord-sessions$\r$\n" + Delete "$0\naia.cmd" + ${EndIf} + ${EndIf} + + ${IfNot} ${FileExists} "$0\naia.cmd" + FileOpen $1 "$0\naia.cmd" w + FileWrite $1 "@echo off$\r$\n" + FileWrite $1 "REM managed by Naia Shell installer$\r$\n" + FileWrite $1 '"$INSTDIR\naia-shell.exe" %*$\r$\n' + FileClose $1 + ${EndIf} +!macroend + !macro NSIS_HOOK_PREUNINSTALL ${If} $UpdateMode <> 1 StrCpy $DeleteAppDataCheckboxState 1 @@ -22,6 +51,18 @@ !macro NSIS_HOOK_POSTUNINSTALL ${If} $UpdateMode <> 1 + ; Remove only the alias created by this installer. A user-owned naia.cmd + ; at the same location must survive uninstall. + StrCpy $0 "$LOCALAPPDATA\Microsoft\WindowsApps\naia.cmd" + ${If} ${FileExists} "$0" + FileOpen $1 "$0" r + FileRead $1 $2 + FileRead $1 $3 + FileClose $1 + ${If} $3 == "REM managed by Naia Shell installer$\r$\n" + Delete "$0" + ${EndIf} + ${EndIf} ; Tauri removes tracked files first. Remove only the exact Naia install ; root afterward so untracked dependency residue cannot survive a full ; uninstall. ~/.naia and user workspaces are deliberately outside it. diff --git a/packages/shell/src/App.tsx b/packages/shell/src/App.tsx index 072f485d0..6101e9893 100644 --- a/packages/shell/src/App.tsx +++ b/packages/shell/src/App.tsx @@ -16,6 +16,7 @@ import { TitleBar } from "./components/TitleBar"; import { UpdateBanner } from "./components/UpdateBanner"; import { UpdatePrompt } from "./components/UpdatePrompt"; import { VideoAvatarCanvas } from "./components/VideoAvatarCanvas"; +import type { WorkspaceAppApi } from "./apps/workspace/WorkspaceCenterArea"; import { getBridgeForPanel } from "./lib/active-bridge"; import { beginNaiaConfigHydration, @@ -212,6 +213,10 @@ export function App() { ); } const [showSplash, setShowSplash] = useState(true); + const [pendingCliFile, setPendingCliFile] = useState<{ + path: string; + requestId: number; + } | null>(null); const [showAdkSetup, setShowAdkSetup] = useState(!isAdkInitialized()); const [localeHydrated, setLocaleHydrated] = useState(showAdkSetup); const [showOnboarding, setShowOnboarding] = useState(false); @@ -285,9 +290,9 @@ export function App() { // until localStorage has been hydrated FROM naia-settings files, so the stale // pre-hydration cache can never be written back into config.json. const configHydratedRef = useRef(false); - const startupUpdateCheckRef = useRef | null>( - null, - ); + const startupUpdateCheckRef = useRef | null>(null); const [updateInfo, setUpdateInfo] = useState(null); const [showUpdatePrompt, setShowUpdatePrompt] = useState(false); const [announcements, setAnnouncements] = useState([]); @@ -731,7 +736,11 @@ export function App() { // Explicit full webview reload. Ctrl+R is captured by the workspace // (document reload) so it never refreshes the app; Ctrl+Shift+R always // reloads — useful to re-launch Herdr / recover a desynced webview. - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "r") { + if ( + (e.ctrlKey || e.metaKey) && + e.shiftKey && + e.key.toLowerCase() === "r" + ) { e.preventDefault(); window.location.reload(); } @@ -763,6 +772,45 @@ export function App() { }; }, []); + // #484: accept both cold-start argv and subsequent single-instance argv. + // Keep the path in memory until the keep-alive Workspace API has mounted; + // never copy it into a URL, storage, or diagnostic log. + useEffect(() => { + let requestId = 0; + const queueFile = (path: string | null | undefined) => { + if (path) setPendingCliFile({ path, requestId: ++requestId }); + }; + const unlisten = listen("workspace-open-file-request", (event) => { + queueFile(event.payload); + }); + void invoke("get_startup_open_file") + .then(queueFile) + .catch(() => {}); + return () => { + unlisten.then((fn) => fn()); + }; + }, []); + + useEffect(() => { + if (!pendingCliFile || showSplash || showAdkSetup || showOnboarding) return; + let attempts = 0; + let retryTimer: number | undefined; + const openWhenReady = () => { + const workspace = appRegistry.getApi("workspace"); + if (!workspace) { + if (++attempts < 40) retryTimer = window.setTimeout(openWhenReady, 50); + return; + } + useAppStore.getState().setActiveApp("workspace"); + workspace.openFile(pendingCliFile.path); + setPendingCliFile((current) => + current?.requestId === pendingCliFile.requestId ? null : current, + ); + }; + openWhenReady(); + return () => window.clearTimeout(retryTimer); + }, [pendingCliFile, showAdkSetup, showOnboarding, showSplash]); + useEffect(() => { const unlisten = listen<{ naiaKey?: string }>( "naia_auth_complete", diff --git a/packages/shell/src/apps/__tests__/editor-viewer.test.tsx b/packages/shell/src/apps/__tests__/editor-viewer.test.tsx index 209da0611..0ca01536e 100644 --- a/packages/shell/src/apps/__tests__/editor-viewer.test.tsx +++ b/packages/shell/src/apps/__tests__/editor-viewer.test.tsx @@ -342,6 +342,36 @@ describe("Editor — file type helpers (via render behaviour)", () => { }); }); + it.each([ + ["track.mp3", "audio", "audio player"], + ["recording.wav", "audio", "audio player"], + ["clip.mp4", "video", "video player"], + ])( + "streams %s through an accessible local media element", + (name, tag, label) => { + render(); + const media = screen.getByLabelText(new RegExp(label, "i")); + expect(media.tagName.toLowerCase()).toBe(tag); + expect(media).toHaveAttribute("src", `asset:///media/${name}`); + expect(media).toHaveAttribute("preload", "metadata"); + expect(mockInvoke).not.toHaveBeenCalledWith( + "workspace_read_file", + expect.anything(), + ); + }, + ); + + it("reports codec errors and changes playback speed", () => { + render(); + const video = screen.getByLabelText(/video player/i) as HTMLVideoElement; + fireEvent.change(screen.getByLabelText("재생 속도"), { + target: { value: "1.5" }, + }); + expect(video.playbackRate).toBe(1.5); + fireEvent.error(video); + expect(screen.getByRole("alert")).toHaveTextContent("지원하지 않는 코덱"); + }); + it("does NOT call workspace_read_file for PDF files", () => { render(); expect(mockInvoke).not.toHaveBeenCalledWith( diff --git a/packages/shell/src/apps/workspace/Editor.tsx b/packages/shell/src/apps/workspace/Editor.tsx index de82a340e..0946461cb 100644 --- a/packages/shell/src/apps/workspace/Editor.tsx +++ b/packages/shell/src/apps/workspace/Editor.tsx @@ -11,7 +11,7 @@ import { shell } from "@codemirror/legacy-modes/mode/shell"; import { EditorState, Transaction } from "@codemirror/state"; import { oneDark } from "@codemirror/theme-one-dark"; import { EditorView, keymap, lineNumbers } from "@codemirror/view"; -import { invoke } from "@tauri-apps/api/core"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import AnsiToHtml from "ansi-to-html"; import DOMPurify from "dompurify"; import Papa from "papaparse"; @@ -27,11 +27,16 @@ import { import { Document, Page as PdfPage, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; -import mermaid from "mermaid"; import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; +import { MermaidBlock } from "../../components/MarkdownCodeBlock"; import { Logger } from "../../lib/logger"; -import { AUTOSAVE_DEBOUNCE_MS } from "./constants"; import { MarkdownPreview } from "./MarkdownPreview"; +import { AUTOSAVE_DEBOUNCE_MS } from "./constants"; +import { + isWorkspaceMediaFile, + resolveWorkspaceViewer, + workspaceMediaMime, +} from "./viewer-registry"; pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; @@ -43,7 +48,9 @@ type ViewMode = | "csv" | "log" | "pdf" - | "hwp"; + | "hwp" + | "audio" + | "video"; interface EditorProps { /** Absolute path of the file being edited. Empty = no file open. */ @@ -113,64 +120,11 @@ function isHwpFile(filePath: string): boolean { } function detectViewMode(filePath: string): ViewMode { - if (isImageFile(filePath)) return "image"; - if (isPdfFile(filePath)) return "pdf"; - if (isCsvFile(filePath)) return "csv"; - if (isLogFile(filePath)) return "log"; - if (isHwpFile(filePath)) return "hwp"; - if (isMarkdownFile(filePath)) return "preview"; - return "editor"; + return resolveWorkspaceViewer(filePath); } const ansiConverter = new AnsiToHtml({ escapeXML: true }); -mermaid.initialize({ - startOnLoad: false, - theme: "dark", - // WebKitGTK does not reliably render in SVG. - // Force pure SVG elements for all labels. - htmlLabels: false, - flowchart: { htmlLabels: false }, - sequence: { useHtmlLabels: false } as Record, -}); - -let mermaidIdCounter = 0; - -function MermaidBlock({ code }: { code: string }) { - const containerRef = useRef(null); - const [error, setError] = useState(null); - - useEffect(() => { - if (!containerRef.current || !code.trim()) return; - const id = `mermaid-${++mermaidIdCounter}`; - let cancelled = false; - mermaid - .render(id, code.trim()) - .then(({ svg }) => { - if (!cancelled && containerRef.current) { - containerRef.current.innerHTML = DOMPurify.sanitize(svg); - setError(null); - } - }) - .catch((err) => { - if (!cancelled) setError(String(err?.message ?? err)); - }); - return () => { - cancelled = true; - }; - }, [code]); - - if (error) { - return ( -
- Mermaid 오류: {error} -
- ); - } - - return
; -} - /** Custom code block renderer — intercepts ```mermaid blocks */ function CodeBlock({ className, @@ -243,6 +197,9 @@ export const Editor = forwardRef(function Editor( } | null>(null); const [imageBlobUrl, setImageBlobUrl] = useState(null); const [pdfBlobUrl, setPdfBlobUrl] = useState(null); + const [mediaError, setMediaError] = useState(""); + const [playbackRate, setPlaybackRate] = useState(1); + const mediaRef = useRef(null); const isMd = filePath ? isMarkdownFile(filePath) : false; @@ -254,6 +211,8 @@ export const Editor = forwardRef(function Editor( setPdfNumPages(0); setImageZoom("fit"); setImageInfo(null); + setMediaError(""); + setPlaybackRate(1); // Revoke previous blob URLs to free memory setImageBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); @@ -385,7 +344,12 @@ export const Editor = forwardRef(function Editor( return; } // Images and PDFs are rendered via blob URL — no text read needed - if (isImageFile(filePath) || isPdfFile(filePath) || isHwpFile(filePath)) { + if ( + isImageFile(filePath) || + isPdfFile(filePath) || + isHwpFile(filePath) || + isWorkspaceMediaFile(filePath) + ) { setContent(""); setLoadError(null); loadErrorRef.current = false; @@ -459,7 +423,12 @@ export const Editor = forwardRef(function Editor( const reloadFile = useCallback(async () => { if (!filePath) return; // Images and PDFs use blob URLs — no text reload needed - if (isImageFile(filePath) || isPdfFile(filePath)) return; + if ( + isImageFile(filePath) || + isPdfFile(filePath) || + isWorkspaceMediaFile(filePath) + ) + return; setReloading(true); try { const text = await invoke("workspace_read_file", { @@ -847,7 +816,70 @@ export const Editor = forwardRef(function Editor(
{/* Viewer / Editor area */} - {viewMode === "image" ? ( + {viewMode === "audio" || viewMode === "video" ? ( +
+ {viewMode === "audio" ? ( + // biome-ignore lint/a11y/useMediaCaption: arbitrary local audio has no companion caption track +
+ ) : viewMode === "image" ? (
+ - + {/* Track name — fixed width, marquee when long, click to toggle panel */} - -
- -
- - {/* ── YouTube tab ── */} - {panelTab === "youtube" && ( - <> -
{ e.preventDefault(); doSearch(searchQuery); }} - > - setSearchQuery(e.target.value)} - /> - -
- -
+ {panelPos && + createPortal( +
+ {/* Mode tab header */} +
+
-
+ +
- {ytView === "categories" && ( -
- {CATEGORIES.map((cat) => ( - - ))} -
- )} - - {ytView === "search" && ( -
- {searching &&
{t("bgm.searching")}
} - {!searching && searchError && ( -
{searchError}
- )} - {!searching && !searchError && searchResults.length === 0 && ( -
{t("bgm.noResults")}
- )} - {searchResults.map((v) => ( - handleYtSelect(v)} - onFav={() => toggleFav(v)} - /> - ))} -
- )} - - {ytView === "favorites" && ( -
- {favs.length === 0 && ( -
{t("bgm.favEmpty")}
- )} - {favs.map((v) => ( - handleYtSelect(v)} - onFav={() => toggleFav(v)} - /> - ))} + {/* ── YouTube tab ── */} + {panelTab === "youtube" && ( + <> +
{ + e.preventDefault(); + doSearch(searchQuery); + }} + > + setSearchQuery(e.target.value)} + /> + +
+ +
+ + +
- )} - - )} - {/* ── Local tab ── */} - {panelTab === "local" && ( -
- {localTracks.length === 0 && ( -
{t("bgm.noTracks")}
- )} - {localTracks.map((url, idx) => { - const isActive = source === "local" && localIndex === idx; - return ( -
playLocalAt(idx)} - onKeyDown={(e) => e.key === "Enter" && playLocalAt(idx)} - role="button" - tabIndex={0} - title={localNames[idx]} - > -
- {isActive && playing ? "▶" : "♪"} -
-
-
{localNames[idx]}
-
+ {ytView === "categories" && ( +
+ {CATEGORIES.map((cat) => ( + + ))}
- ); - })} -
- )} - {/* ── Drawer handle — inside panel so it moves as one unit, no desync ── */} - -
, - document.body, - )} + )} + + {ytView === "search" && ( +
+ {searching && ( +
{t("bgm.searching")}
+ )} + {!searching && searchError && ( +
+ {searchError} +
+ )} + {!searching && + !searchError && + searchResults.length === 0 && ( +
+ {t("bgm.noResults")} +
+ )} + {searchResults.map((v) => ( + handleYtSelect(v)} + onFav={() => toggleFav(v)} + /> + ))} +
+ )} + + {ytView === "favorites" && ( +
+ {favs.length === 0 && ( +
{t("bgm.favEmpty")}
+ )} + {favs.map((v) => ( + handleYtSelect(v)} + onFav={() => toggleFav(v)} + /> + ))} +
+ )} + + )} + + {/* ── Local tab ── */} + {panelTab === "local" && ( +
+ {localTracks.length === 0 && ( +
{t("bgm.noTracks")}
+ )} + {localTracks.map((url, idx) => { + const isActive = source === "local" && localIndex === idx; + return ( +
playLocalAt(idx)} + onKeyDown={(e) => e.key === "Enter" && playLocalAt(idx)} + role="button" + tabIndex={0} + title={localNames[idx]} + > +
+ {isActive && playing ? "▶" : "♪"} +
+
+
+ {localNames[idx]} +
+
+
+ ); + })} +
+ )} + {/* ── Drawer handle — inside panel so it moves as one unit, no desync ── */} + +
, + document.body, + )}
); } @@ -1219,7 +1505,11 @@ function YtTrackRow({ video, loading, playing, fav, onPlay, onFav }: RowProps) { )}
- {loading ? t("bgm.loading") : playing ? "▶ " + video.title : video.title} + {loading + ? t("bgm.loading") + : playing + ? "▶ " + video.title + : video.title}
{video.channel && {video.channel}} @@ -1230,7 +1520,10 @@ function YtTrackRow({ video, loading, playing, fav, onPlay, onFav }: RowProps) { type="button" className={`bgm-yt-fav-btn${fav ? " bgm-yt-fav-btn--on" : ""}`} title={fav ? t("bgm.favRemove") : t("bgm.favAdd")} - onClick={(e) => { e.stopPropagation(); onFav(); }} + onClick={(e) => { + e.stopPropagation(); + onFav(); + }} > {fav ? "★" : "☆"} diff --git a/packages/shell/src/components/ChatArea.tsx b/packages/shell/src/components/ChatArea.tsx index dd912350b..6bbd9b814 100644 --- a/packages/shell/src/components/ChatArea.tsx +++ b/packages/shell/src/components/ChatArea.tsx @@ -8,6 +8,7 @@ import { useState, } from "react"; import Markdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; import { type RecognitionResult, onError as sttOnError, @@ -17,6 +18,7 @@ import { stopListening as sttStop, } from "tauri-plugin-stt-api"; import { activeBridge, getBridgeForPanel } from "../lib/active-bridge"; +import { MarkdownCodeBlock } from "./MarkdownCodeBlock"; import { formatAiInterferencePrompt, onAiInterferenceEvent, @@ -86,6 +88,7 @@ import { isApiKeyOptional, isOmniModel, } from "../lib/llm"; +import { ThinkingStreamFilter } from "../lib/llm/thinking-stream-filter"; import { Logger } from "../lib/logger"; import { type MicStream, createMicStream } from "../lib/mic-stream"; import { appRegistry } from "../lib/app-registry"; @@ -271,6 +274,40 @@ function openFileInWorkspace(path: string): void { useAppStore.getState().setActiveApp("workspace"); } +const CODE_FILE_EXTENSIONS: Record = { + bash: "sh", + css: "css", + html: "html", + javascript: "js", + js: "js", + json: "json", + jsx: "jsx", + markdown: "md", + md: "md", + python: "py", + py: "py", + rust: "rs", + rs: "rs", + sh: "sh", + tsx: "tsx", + typescript: "ts", + ts: "ts", + yaml: "yaml", + yml: "yml", +}; + +async function openCodeInWorkspace( + code: string, + language: string, +): Promise { + const extension = CODE_FILE_EXTENSIONS[language.toLowerCase()] ?? "txt"; + const path = await invoke("write_temp_text", { + filename: `naia-code-${Date.now()}.${extension}`, + content: code, + }); + openFileInWorkspace(path); +} + /** Split a text string on file paths and return an array of strings / buttons. */ function processFilePaths(text: string): ReactNode[] { const parts = text.split(FILE_PATH_RE); @@ -293,6 +330,16 @@ function processFilePaths(text: string): ReactNode[] { /** React-Markdown components override — detects file paths in

text nodes. */ const mdComponents: Components = { + code: ({ className, children }) => ( + + void openCodeInWorkspace(code, language) + } + > + {children} + + ), p({ children, ...props }) { const processed = Array.isArray(children) ? children.flatMap((child) => @@ -644,7 +691,7 @@ export function ChatArea({ const pipelineActiveRef = useRef(false); const audioQueueRef = useRef(null); const sentenceChunkerRef = useRef(null); - const reasoningTextHiddenRef = useRef(false); + const thinkingStreamFilterRef = useRef(new ThinkingStreamFilter()); // FR-VOICE.16 Phase 2a (#420): the 6GB half-duplex admission + adaptive // prebuffer + generation fencing live in lib/tts/local-voice-scheduler so // unrelated ChatArea work cannot regress FR-VOICE.11/12 semantics. @@ -886,7 +933,7 @@ export function ChatArea({ if (!store.isStreaming && !ttsActive) return; // A cancelled response may never deliver its terminal `finish` chunk. // Do not let an unfinished reasoning block hide the next request. - reasoningTextHiddenRef.current = false; + thinkingStreamFilterRef.current.reset(); // Cancelling the response is also a speech barge-in. Without clearing // the TTS generation here, a late avatar failure callback can replay // fallback audio from the response the user just stopped. @@ -1571,7 +1618,7 @@ export function ChatArea({ const requestId = generateRequestId(); // Request state is isolated even when the previous transport terminated // without a final chunk (cancel, disconnect, provider error). - reasoningTextHiddenRef.current = false; + thinkingStreamFilterRef.current.reset(); currentRequestId.current = requestId; setInput(""); @@ -1715,6 +1762,8 @@ export function ChatArea({ ollamaNumGpu: activeProvider === "ollama" ? config.ollamaNumGpu : undefined, vllmHost: activeProvider === "vllm" ? config.vllmHost : undefined, + openaiBaseUrl: + activeProvider === "openai" ? config.openaiBaseUrl : undefined, }, history: history.slice(0, -1), onChunk: (chunk) => handleChunk(chunk, activeProvider), @@ -2009,6 +2058,41 @@ export function ChatArea({ } } + function appendVisibleResponseText(visibleText: string): void { + if (!visibleText) return; + useChatStore.getState().appendStreamChunk(visibleText); + if (ttsTextSyncRef.current.active) { + ttsTextSyncRef.current.canonical += visibleText; + } + // Parse emotion from accumulated text (tag may span multiple chunks) + const accumulated = useChatStore.getState().streamingContent; + if (accumulated.length <= 30 && accumulated.length >= 4) { + const { emotion } = extractExpression(accumulated); + if (emotion) setEmotion(emotion); + } + // Sentence-level TTS — same path for both pipeline and chat mode + if (sentenceChunkerRef.current) { + const sentences = sentenceChunkerRef.current.feed(visibleText); + if (sentences.length > 0) { + Logger.info("ChatArea", "SentenceChunker produced sentences", { + count: sentences.length, + sentences, + }); + } + for (const sentence of sentences) { + sendSentenceToTts(sentence); + } + } + } + + function flushThinkingStream(): void { + const tail = thinkingStreamFilterRef.current.flush(); + if (tail.thinking) { + useChatStore.getState().appendThinkingChunk(tail.thinking); + } + appendVisibleResponseText(tail.visible); + } + function handleChunk(chunk: AgentResponseChunk, activeProvider: ProviderId) { const store = useChatStore.getState(); @@ -2040,56 +2124,11 @@ export function ChatArea({ switch (chunk.type) { case "text": { - let visibleText = chunk.text; - const htmlThinkOpen = /^\s*/i.test(visibleText); - const bracketThinkOpen = /^\s*\[THINK\]/i.test(visibleText); - // `[THINK]` is also the documented avatar-expression tag. Treat it as - // reasoning only when this chunk proves the paired `` form; - // otherwise a normal expressive answer would disappear until finish. - const opensReasoning = - htmlThinkOpen || (bracketThinkOpen && /<\/think>/i.test(visibleText)); - if (opensReasoning) reasoningTextHiddenRef.current = true; - if (reasoningTextHiddenRef.current) { - const close = visibleText.search(/<\/think>/i); - if (close < 0) { - store.appendThinkingChunk( - visibleText.replace(/^\s*(?:|\[THINK\])\s*/i, ""), - ); - break; - } - store.appendThinkingChunk( - visibleText - .slice(0, close) - .replace(/^\s*(?:|\[THINK\])\s*/i, ""), - ); - visibleText = visibleText.slice(close + "".length); - reasoningTextHiddenRef.current = false; - } - visibleText = visibleText.replace(/<\/?think>/gi, ""); - if (!visibleText) break; - store.appendStreamChunk(visibleText); - if (ttsTextSyncRef.current.active) { - ttsTextSyncRef.current.canonical += visibleText; - } - // Parse emotion from accumulated text (tag may span multiple chunks) - const accumulated = useChatStore.getState().streamingContent; - if (accumulated.length <= 30 && accumulated.length >= 4) { - const { emotion } = extractExpression(accumulated); - if (emotion) setEmotion(emotion); - } - // Sentence-level TTS — same path for both pipeline and chat mode - if (sentenceChunkerRef.current) { - const sentences = sentenceChunkerRef.current.feed(visibleText); - if (sentences.length > 0) { - Logger.info("ChatArea", "SentenceChunker produced sentences", { - count: sentences.length, - sentences, - }); - } - for (const sentence of sentences) { - sendSentenceToTts(sentence); - } + const separated = thinkingStreamFilterRef.current.push(chunk.text); + if (separated.thinking) { + store.appendThinkingChunk(separated.thinking); } + appendVisibleResponseText(separated.visible); break; } case "thinking": @@ -2159,7 +2198,7 @@ export function ChatArea({ break; } case "finish": - reasoningTextHiddenRef.current = false; + flushThinkingStream(); // Flush remaining text to TTS (both pipeline and chat mode) if (sentenceChunkerRef.current) { const remaining = sentenceChunkerRef.current.flush(); @@ -2242,6 +2281,7 @@ export function ChatArea({ Logger.warn("ChatArea", "Agent error chunk", { message: chunk.message, }); + flushThinkingStream(); // Flush any partial sentence before finishing. Chat TTS needs the same // terminal behavior as pipeline voice or its mask can remain pending. if (sentenceChunkerRef.current) { @@ -3727,7 +3767,11 @@ export function ChatArea({ ))}

{msg.role === "assistant" ? ( - + { extractExpression( msg.id === effectiveTtsMaskedMessageId @@ -3754,7 +3798,7 @@ export function ChatArea({ {isStreaming && (
{streamingThinking && ( -
+
💭 {t("chat.thinking") || "Thinking..."} @@ -3770,7 +3814,11 @@ export function ChatArea({ ))}
{streamingContent ? ( - + { extractExpression( ttsTextSyncRef.current.active diff --git a/packages/shell/src/components/MarkdownCodeBlock.tsx b/packages/shell/src/components/MarkdownCodeBlock.tsx new file mode 100644 index 000000000..c85e5a5e1 --- /dev/null +++ b/packages/shell/src/components/MarkdownCodeBlock.tsx @@ -0,0 +1,101 @@ +import DOMPurify from "dompurify"; +import hljs from "highlight.js/lib/common"; +import mermaid from "mermaid"; +import { type ReactNode, useEffect, useRef, useState } from "react"; + +mermaid.initialize({ + startOnLoad: false, + theme: "dark", + securityLevel: "strict", + htmlLabels: false, + flowchart: { htmlLabels: false }, + sequence: { useHtmlLabels: false } as Record, +}); + +let mermaidIdCounter = 0; + +export function highlightCode(code: string, language: string): string { + const normalized = language.toLowerCase(); + const highlighted = hljs.getLanguage(normalized) + ? hljs.highlight(code, { language: normalized, ignoreIllegals: true }).value + : hljs.highlight(code, { language: "plaintext" }).value; + return DOMPurify.sanitize(highlighted, { + ALLOWED_TAGS: ["span"], + ALLOWED_ATTR: ["class"], + }); +} + +/** Shared safe Mermaid renderer for chat and workspace Markdown. */ +export function MermaidBlock({ code }: { code: string }) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!containerRef.current || !code.trim()) return; + const id = `mermaid-${++mermaidIdCounter}`; + let cancelled = false; + mermaid + .render(id, code.trim()) + .then(({ svg }) => { + if (!cancelled && containerRef.current) { + containerRef.current.innerHTML = DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + setError(null); + } + }) + .catch((cause) => { + if (!cancelled) setError(String(cause?.message ?? cause)); + }); + return () => { + cancelled = true; + }; + }, [code]); + + if (error) { + return ( +
+
Mermaid 오류 — 원문을 표시합니다.
+
{code}
+
+ ); + } + return
; +} + +interface MarkdownCodeBlockProps { + className?: string; + children?: ReactNode; + onOpenWorkspace?: (code: string, language: string) => void; +} + +export function MarkdownCodeBlock({ + className, + children, + onOpenWorkspace, +}: MarkdownCodeBlockProps) { + const language = /language-([\w-]+)/.exec(className ?? "")?.[1] ?? "text"; + const code = String(children).replace(/\n$/, ""); + if (language.toLowerCase() === "mermaid") return ; + // react-markdown also calls this override for inline code. + if (!className) return {children}; + return ( +
+ {language} +
+ + {onOpenWorkspace ? ( + + ) : null} +
+
+				{/* DOMPurify restricts this highlighted fragment to span/class only. */}
+				{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized syntax-highlight markup */}
+				
+			
+
+ ); +} diff --git a/packages/shell/src/components/PermissionModal.tsx b/packages/shell/src/components/PermissionModal.tsx index 1ef08aa19..22f9f78cf 100644 --- a/packages/shell/src/components/PermissionModal.tsx +++ b/packages/shell/src/components/PermissionModal.tsx @@ -1,8 +1,13 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { t } from "../lib/i18n"; import type { PendingApproval } from "../stores/chat"; import { useAppStore } from "../stores/app"; +import { + permissionDecisionFromKeyboardEvent, + permissionShortcutLabel, + type PermissionDecision, +} from "../lib/permission-shortcuts"; interface Props { pending: PendingApproval; @@ -15,6 +20,15 @@ export function PermissionModal({ pending, onDecision }: Props) { const tierClass = pending.tier >= 2 ? "tier-2" : "tier-1"; const pushModal = useAppStore((s) => s.pushModal); const popModal = useAppStore((s) => s.popModal); + const settledRef = useRef(false); + const decide = useCallback( + (decision: PermissionDecision) => { + if (settledRef.current) return; + settledRef.current = true; + onDecision(decision); + }, + [onDecision], + ); // Hide Chrome X11 embed while permission modal is visible useEffect(() => { @@ -22,6 +36,18 @@ export function PermissionModal({ pending, onDecision }: Props) { return () => popModal(); }, [pushModal, popModal]); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const decision = permissionDecisionFromKeyboardEvent(event); + if (!decision) return; + event.preventDefault(); + event.stopPropagation(); + decide(decision); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [decide]); + return createPortal(
@@ -42,23 +68,23 @@ export function PermissionModal({ pending, onDecision }: Props) {
diff --git a/packages/shell/src/components/SettingsTab.tsx b/packages/shell/src/components/SettingsTab.tsx index e330b58ce..8065b6d78 100644 --- a/packages/shell/src/components/SettingsTab.tsx +++ b/packages/shell/src/components/SettingsTab.tsx @@ -96,6 +96,7 @@ import { fetchNaiaModelMetadata, fetchNaiaPricing, fetchOllamaModels, + fetchOpenAIModels, fetchVllmModels, formatModelLabel, getDefaultLlmModel, @@ -778,6 +779,10 @@ export function SettingsTab() { const [vllmHost, setVllmHost] = useState( existing?.vllmHost ?? DEFAULT_VLLM_HOST, ); + const [openaiBaseUrl, setOpenaiBaseUrl] = useState( + existing?.openaiBaseUrl ?? "", + ); + const [openaiConnected, setOpenaiConnected] = useState(false); // Naia Local: ws:// address of the user's own omni-24g container (shown when // the `naia-local` model is selected). Reuses the logged-in key — no key input. const [naiaLocalUrl, setNaiaLocalUrl] = useState( @@ -1783,6 +1788,25 @@ export function SettingsTab() { }); }, [provider, vllmHost]); + useEffect(() => { + if (provider !== "openai") return; + let cancelled = false; + fetchOpenAIModels(openaiBaseUrl, apiKey).then(({ models, connected }) => { + if (cancelled) return; + setOpenaiConnected(connected); + if (models.length === 0) return; + setDynamicModels((previous) => ({ ...previous, openai: models })); + setModel((current) => + models.some((candidate) => candidate.id === current) + ? current + : models[0].id, + ); + }); + return () => { + cancelled = true; + }; + }, [provider, openaiBaseUrl, apiKey]); + // Fetch live Naia pricing from gateway (DB = SoT). // Runs when provider switches to "nextain" so the displayed price always // matches what the gateway actually charges. @@ -2845,6 +2869,10 @@ export function SettingsTab() { : existing?.ollamaHost, vllmHost: provider === "vllm" ? vllmHost.trim() || undefined : existing?.vllmHost, + openaiBaseUrl: + provider === "openai" + ? openaiBaseUrl.trim() || undefined + : existing?.openaiBaseUrl, naiaLocalUrl: naiaLocalUrl.trim() || undefined, voice: isOmniModel(provider, model) ? voice : existing?.voice, openaiRealtimeApiKey: openaiRealtimeApiKey.trim() || undefined, @@ -4056,9 +4084,9 @@ export function SettingsTab() { unknown >, ); - await writeSlotsManifest(loggedOutConfig); - await sendAuthUpdateStrict(""); - await reloadAgentSettings(); + await writeSlotsManifest(loggedOutConfig); + await sendAuthUpdateStrict(""); + await reloadAgentSettings(); } }} > @@ -4443,30 +4471,30 @@ export function SettingsTab() { {provider !== "nextain" && (!isApiKeyOptional(provider) || provider === "ollama") && ( -
- - { - setApiKey(e.target.value); - setError(""); - }} - placeholder={ - hasStoredApiKey - ? "•••••••• (저장됨 — 변경하려면 입력)" - : "sk-..." - } - /> - {provider === "zai" && ( -
- Z.AI Coding Plan 구독 후 발급된 API Key를 - 입력하세요. -
- )} -
- )} +
+ + { + setApiKey(e.target.value); + setError(""); + }} + placeholder={ + hasStoredApiKey + ? "•••••••• (저장됨 — 변경하려면 입력)" + : "sk-..." + } + /> + {provider === "zai" && ( +
+ Z.AI Coding Plan 구독 후 발급된 API Key를 + 입력하세요. +
+ )} +
+ )} {provider === "ollama" && (
@@ -4502,6 +4530,28 @@ export function SettingsTab() {
)} + {provider === "openai" && ( +
+ + setOpenaiBaseUrl(event.target.value)} + onBlur={(event) => + persistConfig({ + openaiBaseUrl: event.target.value.trim() || undefined, + }) + } + placeholder="https://api.openai.com/v1" + /> +
+ {openaiConnected ? "연결됨" : "연결을 확인하세요"} — 비워 두면 + OpenAI 공식 endpoint를 사용합니다. OpenAI 호환 서버는 + http://host:port/v1 형식으로 입력하세요. +
+
+ )}
@@ -4552,70 +4602,72 @@ export function SettingsTab() { /> {displayedProviderModels - .filter((candidate) => !candidate.capabilities.includes("asr")) + .filter( + (candidate) => !candidate.capabilities.includes("asr"), + ) .map((candidate) => ( ) : ( - candidate.id).join(":")}`} + id="model-select" + value={hasSelectedModel ? model : "__custom__"} + onChange={(e) => { + if (e.target.value === "__custom__") return; + setModel(e.target.value); + // UC-MODEL-SELECT contract: persist the selection immediately so the gRPC + // agent loads THIS model. Previously only Save persisted → a stale model + // (e.g. an omni gemini-2.5-flash-live from a prior voice session) survived. + // Skip while a nextain login is pending (naia_auth_complete persists then). + if (!(provider === "nextain" && !naiaKey)) { + const legacySelection = applyModelSelectionToConfig( + loadConfig() as Record | null, + provider, + e.target.value, + ); + const nextSel = writeConfiguredLlmRole( + legacySelection as unknown as AppConfig, + "main", + { provider, model: e.target.value }, + ); + saveConfig( + nextSel as unknown as Parameters[0], + ); + void writeNaiaConfig( + nextSel as unknown as Record, + ); + } + // When switching to an omni model, set default voice if not already set + const newMeta = providerModels.find( + (m) => m.id === e.target.value, ); - if (!currentVoiceValid) { - setVoice(newMeta.voices[0].id); + if ( + newMeta?.capabilities.includes("omni") && + newMeta.voices?.length + ) { + const currentVoiceValid = newMeta.voices.some( + (v) => v.id === voice, + ); + if (!currentVoiceValid) { + setVoice(newMeta.voices[0].id); + } } - } - }} - > - {!hasSelectedModel && model ? ( - - ) : null} - {displayedProviderModels - .filter((m) => !m.capabilities.includes("asr")) - .map((m) => ( - - ))} - + }} + > + {!hasSelectedModel && model ? ( + + ) : null} + {displayedProviderModels + .filter((m) => !m.capabilities.includes("asr")) + .map((m) => ( + + ))} + )}
{provider === "nextain" && selectedModelMeta?.pricing ? ( diff --git a/packages/shell/src/components/__tests__/BgmPlayer.test.tsx b/packages/shell/src/components/__tests__/BgmPlayer.test.tsx index 2a38daeaa..45dd87ed9 100644 --- a/packages/shell/src/components/__tests__/BgmPlayer.test.tsx +++ b/packages/shell/src/components/__tests__/BgmPlayer.test.tsx @@ -98,7 +98,9 @@ describe("BgmPlayer YouTube playback state machine", () => { await enqueueTrack("v2", "Song Two (should not play)"); const events: string[] = []; - const unlisten = onAiInterferenceEvent((event) => events.push(event.action)); + const unlisten = onAiInterferenceEvent((event) => + events.push(event.action), + ); // No onStateChange/infoDelivery ever arrives — simulates a lost status // message on a track that may actually be playing fine. @@ -107,21 +109,53 @@ describe("BgmPlayer YouTube playback state machine", () => { await Promise.resolve(); }); - expect(container.querySelector(".bgm-player")?.getAttribute( - "data-bgm-current-title", - )).toBe("Song One (watchdog)"); expect( - container.querySelector(".bgm-player")?.getAttribute( - "data-bgm-playback-status", - ), + container + .querySelector(".bgm-player") + ?.getAttribute("data-bgm-current-title"), + ).toBe("Song One (watchdog)"); + expect( + container + .querySelector(".bgm-player") + ?.getAttribute("data-bgm-playback-status"), ).toBe("timeout"); // The old behavior called recoverAfterQueueExhausted here, which would // have promoted "Song Two" from the queue. It must not have. expect(bgmPlayback.queue().length).toBe(1); + expect(events).toContain("music_timeout"); expect(events).not.toContain("music_ended"); unlisten(); }); + it("notifies the agent when late progress recovers a diagnostic timeout", async () => { + vi.useFakeTimers(); + const { container } = render(); + await startTrack("v1", "Late Song"); + attachIframeForCurrentPlayback(); + const events: string[] = []; + const unlisten = onAiInterferenceEvent((event) => + events.push(event.action), + ); + + await act(async () => { + vi.advanceTimersByTime(13_000); + await Promise.resolve(); + }); + postYtMessage({ + event: "infoDelivery", + info: { currentTime: 2, duration: 180 }, + }); + + expect(container.querySelector(".bgm-player")).toHaveAttribute( + "data-bgm-playback-status", + "playing", + ); + expect(events).toEqual( + expect.arrayContaining(["music_timeout", "music_recovered"]), + ); + unlisten(); + }); + it("promotes requested/loading to playing from infoDelivery progress alone, surviving a lost onStateChange message", async () => { vi.useFakeTimers(); const { container } = render(); @@ -130,12 +164,15 @@ describe("BgmPlayer YouTube playback state machine", () => { // No onStateChange "playing" (state=1) ever arrives — only infoDelivery, // simulating the documented WebView2 handshake-loss case. - postYtMessage({ event: "infoDelivery", info: { currentTime: 5, duration: 200 } }); + postYtMessage({ + event: "infoDelivery", + info: { currentTime: 5, duration: 200 }, + }); expect( - container.querySelector(".bgm-player")?.getAttribute( - "data-bgm-playback-status", - ), + container + .querySelector(".bgm-player") + ?.getAttribute("data-bgm-playback-status"), ).toBe("playing"); // The 12s watchdog must not override a status it already knows is playing. @@ -144,9 +181,9 @@ describe("BgmPlayer YouTube playback state machine", () => { await Promise.resolve(); }); expect( - container.querySelector(".bgm-player")?.getAttribute( - "data-bgm-playback-status", - ), + container + .querySelector(".bgm-player") + ?.getAttribute("data-bgm-playback-status"), ).toBe("playing"); }); diff --git a/packages/shell/src/components/__tests__/ChatArea.test.tsx b/packages/shell/src/components/__tests__/ChatArea.test.tsx index 4c9b67d34..202f98e27 100644 --- a/packages/shell/src/components/__tests__/ChatArea.test.tsx +++ b/packages/shell/src/components/__tests__/ChatArea.test.tsx @@ -43,10 +43,12 @@ vi.mock("../../lib/tts/synthesize", () => ({ vi.mock("../../lib/voice/audio-queue", () => ({ AudioQueue: class { - constructor(callbacks: { - onPlaybackStart?: () => void; - onPlaybackEnd?: () => void; - } = {}) { + constructor( + callbacks: { + onPlaybackStart?: () => void; + onPlaybackEnd?: () => void; + } = {}, + ) { ttsSyncMocks.audioQueueCallbacks = callbacks; } get isActive() { @@ -455,6 +457,79 @@ describe("ChatArea", () => { expect(screen.getByText(/응답 중/)).toBeDefined(); }); + it("separates split think-tag text and keeps streaming reasoning collapsed", async () => { + localStorage.setItem( + "naia-config", + JSON.stringify({ + apiKey: "test-key", + provider: "gemini", + model: "gemini-2.5-flash", + }), + ); + render(); + const input = screen.getByPlaceholderText(/message/i); + fireEvent.change(input, { target: { value: "reason carefully" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(capturedRequests).toHaveLength(1)); + const request = capturedRequests[0]; + for (const text of [ + "private chain", + "Final answer.", + ]) { + request.onChunk({ type: "text", requestId: request.requestId, text }); + } + + expect(useChatStore.getState().streamingThinking).toBe("private chain"); + expect(useChatStore.getState().streamingContent).toBe("Final answer."); + const reasoning = await screen.findByText("private chain"); + expect(reasoning.closest("details")?.hasAttribute("open")).toBe(false); + + request.onChunk({ type: "finish", requestId: request.requestId }); + const assistant = useChatStore + .getState() + .messages.find((message) => message.role === "assistant"); + expect(assistant?.thinking).toBe("private chain"); + expect(assistant?.content).toBe("Final answer."); + localStorage.removeItem("naia-config"); + }); + + it("never sends textual reasoning to sentence TTS", async () => { + localStorage.setItem( + "naia-config", + JSON.stringify({ + apiKey: "test-key", + provider: "gemini", + model: "gemini-2.5-flash", + ttsEnabled: true, + ttsProvider: "edge", + }), + ); + render(); + const input = screen.getByPlaceholderText(/message/i); + fireEvent.change(input, { target: { value: "speak final only" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(capturedRequests).toHaveLength(1)); + const request = capturedRequests[0]; + for (const text of [ + "Secret sentence.", + " Still secret.", + "Public sentence.", + ]) { + request.onChunk({ type: "text", requestId: request.requestId, text }); + } + request.onChunk({ type: "finish", requestId: request.requestId }); + + await waitFor(() => expect(ttsSyncMocks.synthesizeTts).toHaveBeenCalled()); + expect( + ttsSyncMocks.synthesizeTts.mock.calls.map(([input]) => input.text), + ).toEqual(["Public sentence."]); + localStorage.removeItem("naia-config"); + }); + it("renders ToolActivity for tool_use chunk during streaming", async () => { // Set up API key so sendChatMessage is actually called localStorage.setItem( @@ -912,8 +987,7 @@ describe("ChatArea", () => { model: "gemini-2.5-flash", ttsEnabled: true, ttsProvider: "naia-local-voice", - voiceRefUrl: - "http://127.0.0.1:8910/ref/audio/ref_ko_485.wav", + voiceRefUrl: "http://127.0.0.1:8910/ref/audio/ref_ko_485.wav", vllmTtsHost: "http://localhost:8910", }), ); @@ -1068,12 +1142,14 @@ describe("ChatArea", () => { }); it("reveals text without duplicate AudioQueue playback when media runtime fails", async () => { - const playAuthoredClip = vi.fn().mockImplementation( - (_text: string, opts: { onPlaybackFailure?: () => void }) => { - opts.onPlaybackFailure?.(); - return Promise.resolve(); - }, - ); + const playAuthoredClip = vi + .fn() + .mockImplementation( + (_text: string, opts: { onPlaybackFailure?: () => void }) => { + opts.onPlaybackFailure?.(); + return Promise.resolve(); + }, + ); useCascadeAvatarStore.setState({ renderer: { hasAuthoredClip: () => true, @@ -1324,9 +1400,7 @@ describe("ChatArea", () => { await waitFor(() => expect(useAvatarStore.getState().currentEmotion).toBe("neutral"), ); - expect( - screen.getByText(/Can't reach the host voice engine/), - ).toBeDefined(); + expect(screen.getByText(/Can't reach the host voice engine/)).toBeDefined(); localStorage.removeItem("naia-config"); }); diff --git a/packages/shell/src/components/__tests__/MarkdownCodeBlock.test.tsx b/packages/shell/src/components/__tests__/MarkdownCodeBlock.test.tsx new file mode 100644 index 000000000..2391b48ec --- /dev/null +++ b/packages/shell/src/components/__tests__/MarkdownCodeBlock.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + highlightCode, + MarkdownCodeBlock, + MermaidBlock, +} from "../MarkdownCodeBlock"; + +const renderMermaid = vi.fn(); +vi.mock("mermaid", () => ({ + default: { initialize: vi.fn(), render: (...args: unknown[]) => renderMermaid(...args) }, +})); + +describe("MarkdownCodeBlock", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it("shows language controls and copies the exact code", () => { + const openWorkspace = vi.fn(); + render( + + {"const answer = 42;\n"} + , + ); + fireEvent.click(screen.getByRole("button", { name: "복사" })); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const answer = 42;"); + fireEvent.click(screen.getByRole("button", { name: "워크스페이스에서 열기" })); + expect(openWorkspace).toHaveBeenCalledWith("const answer = 42;", "ts"); + }); + + it("highlights known languages and escapes executable markup", () => { + expect(highlightCode("const answer = 42;", "typescript")).toContain( + "hljs-keyword", + ); + const hostile = highlightCode('', "html"); + expect(hostile).not.toContain("