Compare commits

10 Commits
Author SHA1 Message Date
kimandClaude Sonnet 5 724a04dbac fix: 밀링캠 PTZ가 만료된 세션 토큰 때문에 조용히 죽어있던 문제
옥토프린트(octoprint-mill) 웹캠 오버레이(control.jinja2, 컨테이너 안에 정적 스크립트로
박혀있음, homeclaw repo엔 없음)가 /api/nvr/ptz를 부를 때 로그인 세션 토큰을 그대로 박아
써왔다. 세션은 7일 지나면 만료되는데(SESSION_MAX_AGE_MS) 09-17에 만든 토큰이 09-24경
만료되며 PTZ 전체(상하좌우 다)가 401로 죽었다 — 오버레이 JS가 fetch 에러를 그냥 삼켜서
화면엔 아무 표시도 없었다. 사용자는 틸트만 시도해서 '틸팅이 안 된다'로 신고했지만 실은
전 방향이 죽어 있었다(2026-09-28 실측).

고친 내용:
- gateway.auth_token(vault 저장, 안 만료되는 고정 토큰)을 새로 발급하고, /api/nvr/ptz와
  /api/nvr/mjpeg-health 두 라우트가 이걸 세션 대신/추가로 받아들이게 함(nvr.ts,
  requireGatewayAuth 적용).
- 그런데 requireGatewayAuth의 Bearer/X-Gateway-Token 분기는 사실 이전부터 죽은 코드였다 —
  이보다 먼저 도는 전역 인증 미들웨어(server.ts)가 activeSessions(로그인 세션)만 알고
  gateway.auth_token 자체는 몰라서 그 앞에서 무조건 401을 내고 있었다. 전역 미들웨어에
  /api/nvr/ptz, /api/nvr/mjpeg-health 두 경로만 gateway.auth_token도 인정하는 예외를
  추가(req.user는 안 심음 — 다른 라우트의 role 체크에 영향 없게).
- auth_token은 config.json에 평문으로 안 두고 다른 시크릿처럼 vault:gateway.auth_token
  참조로 저장(resolveSecret으로 해석).

옥토프린트 컨테이너의 control.jinja2도 새 토큰으로 패치(런타임 파일이라 이 커밋엔 안
잡힘 — 백업은 옥토서버 /home/kim/octoprint-mill-overlay/). 실제 카메라(ONVIF)까지
Up/Down/Left/Right 전부 {ok:true} 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 10:47:49 +09:00
kimandClaude Sonnet 5 54461fd26b fix: weather_openmeteo가 maxHourlyRows로 자른 걸 숨기던 문제
hours=168처럼 도구 스펙상 정상 범위(최대 384)를 요청해도 시간별 표는 maxHourlyRows(기본 48)에서
조용히 잘렸는데, 헤더는 여전히 '지금부터 168시간'이라고 적어 잘렸다는 사실을 모델에게 숨겼다.
그 결과 모델이 표에 없는 뒷부분을 지어내거나(다른 경로로 API를 재호출해 우회) 반대로 '데이터
부족'이라 정직하게 오보고했다(2026-09-27 실측, gemini-3.5-flash-lite로 '한주간 예보' 질의).

maxHourlyRows가 실제 병목(API엔 더 있는데 표만 자른 경우)일 때만 잘렸다고 명시하고 daily 변수로
재요청하라고 안내. API가 애초에 적게 준 경우(정상적 데이터 부족)는 기존 동작 유지 — 이 구분을
놓친 첫 시도는 기존 테스트가 실패로 잡아줬다. 회귀 테스트 2건 추가, 전체 736개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:44:23 +09:00
kimandClaude Sonnet 5 85254e5f0d fix: 음성 통화 — 말 끊김(재생 종료 판단·끼어들기)과 표·가격 낭독 누락
- 끊김: 마지막 문장 합성이 끝나는 순간 '듣는 중'으로 돌아가 아직 재생 중인 자기 목소리를 사용자
  발화/끼어들기로 잡던 문제. 플레이어 워클릿이 active/idle 신호를 보내고, 합성·대기열·재생이 모두
  끝나야 listening으로 복귀. 말하는 중 끼어들기는 더 크게(RMS 0.06)·오래(500ms), 재생 직후
  500ms는 잔향 유예
- 낭독 누락: 천 단위 쉼표(9,500원→'구, 오백원', 천 소실) 제거, 표 행에 마침표를 붙여 표 규칙에
  안 걸리던 문제(|---| 그대로 합성), '일만 이천구백원'이 TTS에서 통째로 사라지는 문제
  ('만 이천구백원'으로 띄어 읽기). TTS→STT 왕복으로 가격 5종 정상 확인

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 23:49:14 +09:00
kimandClaude Sonnet 5 1a32803ef5 feat: 의료탭 시진·청진 캡처 — 라이브 카메라와 원음 청진 녹음
- 시진: camera.js 라이브 카메라(메시지마다 프레임 자동 첨부) + 현재 화면을 환자 기록 자료로 저장
- 청진: 잡음제거·자동음량을 끈 원음 녹음(입력장치 선택, 실시간 파형, 부위 태그) → 무손실 WAV로 기록에 저장
- 자료 탭에 청진·오디오 분류와 재생 플레이어 추가(서버 분류는 다른 앱과 공유라 확장자로 클라이언트 보정)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 23:31:18 +09:00
kimandClaude Sonnet 5 7c7ca45e44 fix: 실시간 음성 통화 — 워클릿 경로 깨짐·시작 실패 무안내·답변 반복 낭독
- addModule('voice-call-worklets.js') 상대경로가 07-25 web-ui 폴더 정리 뒤 HTML(catch-all)을 돌려줘
  통화가 시작 직후 멈췄다 → 절대경로 /js/voice/voice-call-worklets.js
- 시작 단계 예외 시 버튼이 📵에 갇히던 문제: try/catch로 정리 후 오류 안내
- 최종 답변(finalAnswer)이 토큰 스트림 뒤에 통째로 다시 들어와 읽은 위치가 0으로 리셋되면
  같은 문장을 다시 읽던 문제: 턴 단위 문장 키로 중복 낭독 방지
- 통화 종료 사유를 콘솔에 기록(사용자/서버/네트워크 구분)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 23:31:18 +09:00
kimandClaude Sonnet 5 57cf65fd7e feat: 채팅·작업실 라이브 카메라 — 메시지마다 현재 화면 자동 첨부
- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려
  기존 이미지 첨부 경로(![📷…](/api/files/…) → resolveImageContent)로 모델에 전달. 음성 통화 STT도
  같은 전송 경로라 그대로 동작
- 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부
- 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장
- handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 20:37:16 +09:00
kimandClaude Sonnet 5 cfcd2aa829 config: 폴백 모델을 mistral-large-3:675b-cloud로 변경
models.fallback kimi-k2.6 → mistral-large-3, 보조(secondary) 모델 glm-5.3-flash → mistral-large-3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:55:57 +09:00
kimandClaude Sonnet 5 6626132eaf fix: 종료된 deepseek-v4-flash:cloud → gemma4:31b-cloud (기억 재랭킹·Google 폴백)
Ollama Cloud가 2026-09-25에 deepseek-v4-flash를 종료해 재랭킹이 HTTP 410으로 실패하고
거리 임계값 폴백만 쓰이던 문제.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:47:43 +09:00
kimandClaude Code 03a462d72e fix: 작업실 채팅의 링크가 클릭 불가 평문이던 문제
- 작업실 채팅 renderMd에 링크 지원이 아예 없었다(굵게·표·리스트만 구현) — 모델이
  표에 넣어준 출처 링크([바로가기](https://www.printables.com/model/145094))를
  클릭해도 아무 일 없었음(2026-09-25 사용자 실측).
- [라벨](URL) → <a target=_blank> 변환 추가 + 벗겨진 벌주 URL도 자동 링크.
  이스케이프 이후 처리라 URL의 &가 &amp;로 href에 들어가는 것까지 확인.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:36:47 +09:00
kimandClaude Code b4f9a3df9f feat: ACTION-PROMISE 가드 — 실행 예고만 하고 도구 0개로 끝난 턴 1회 넛지
- 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 모델이 '먼저 GitHub에서 관련
  리포지토리를 찾겠습니다'만 내놓고 도구 0개로 턴 종료, 사용자가 '?'로 되물어도
  사과+예고만 반복. deferral 넛지는 '잠시만 기다려'류만 잡고 실행형 요청은 아예
  제외라 이 케이스가 두 망 다 통과했다.
- isActionPromiseNoTool: 답 끝부분(마지막 220자)이 행동 동사+미래 서약
  (~하겠습니다/~해 보겠습니다/~시작합니다) 또는 let me / I'll … 로 끝나는지.
  과거형·의문형·의례적 맺음말은 제외, 800자 이상 완성 답변도 제외.
- handleChat: 이번 턴 도구 호출 0개일 때만 1회 넛지(라운드 소진 방지), 로그줄
  '[v2] ACTION-PROMISE POST-CHECK'가 발동 증거. 테스트 3건(실측 문구 포함) 추가.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:30:16 +09:00
21 changed files with 739 additions and 55 deletions
+4 -3
View File
@@ -6,7 +6,8 @@
"auth": {
"enabled": true,
"multiUser": true
}
},
"auth_token": "vault:gateway.auth_token"
},
"ollama": {
"endpoint": "http://localhost:11434",
@@ -57,7 +58,7 @@
},
"models": {
"primary": "gemma4:31b-cloud",
"fallback": "kimi-k2.6:cloud",
"fallback": "mistral-large-3:675b-cloud",
"roles": {
"manager": "gemma4:31b-cloud",
"executor": "gemma4:31b-cloud",
@@ -206,7 +207,7 @@
"enabled": false,
"secondary": {
"provider": "ollama",
"model": "glm-5.3-flash:cloud"
"model": "mistral-large-3:675b-cloud"
},
"triggers": {
"consecutive_failures": 2,
+30 -1
View File
@@ -22,6 +22,7 @@ import {
hasConcreteCompletion,
isIntentOnlyReply,
isDeferralPromiseReply,
isActionPromiseNoTool,
hasUnversionedNewestIntent,
assumedGenerationTokenInQuery,
isMessagingRequest,
@@ -444,6 +445,7 @@ async function handleChat(
// 직전 라운드가 가드 재프롬프트였는지 — 예고만 한 응답을 걸러낼 때 이 조건으로 좁힌다.
let lastRoundWasGuardReprompt = false;
let intentOnlyNudges = 0;
let actionPromiseNudges = 0;
const MAX_NUMERIC_GROUNDING_FORCED_RETRIES = 1;
let searchAbandonmentForcedRetries = 0;
const MAX_SEARCH_ABANDONMENT_FORCED_RETRIES = 1;
@@ -862,7 +864,12 @@ async function handleChat(
// Only resolve image markdown for user messages — assistant messages with
// /api/files/ image references must stay as plain text so Ollama (which
// rejects image_url content parts on assistant turns) doesn't error.
const content = role === 'user' ? resolveImageContent(msg.content, workspacePath) : msg.content;
// 라이브 카메라 프레임(alt가 "📷"로 시작)은 매 턴 붙어 오므로, 과거 턴의 것까지 전부 다시 모델에
// 실으면 세션이 길수록 비전 입력이 선형으로 쌓인다. 이전 프레임은 이미 답변에 반영됐으니 텍스트로 접는다.
const foldedContent = role === 'user' && typeof msg.content === 'string'
? msg.content.replace(/!\[📷[^\]]*\]\([^)]*\)/g, '[이전 카메라 프레임 생략]')
: msg.content;
const content = role === 'user' ? resolveImageContent(foldedContent, workspacePath) : msg.content;
messages.push({ role, content });
}
messages.push({ role: 'user', content: resolveImageContent(message, workspacePath) });
@@ -1906,6 +1913,28 @@ async function handleChat(
}
// ACTION-PROMISE POST-CHECK (2026-09-25). 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서
// 관련 리포지토리를 찾겠습니다"만 내놓고 도구 0개로 턴 종료 — 사용자가 "?"로 되물어야 사과
// 또 예고만 반복. deferral 넛지는 "잠시만 기다려"류만 잡고 실행형 요청은 아예 제외라 이
// 케이스가 두 망 다 통과했다. 행동 예고로 끝났는데 이번 턴 도구 호출이 0개면 1회 넛지 —
// 지금 실행하게 하거나, 못 하는 사정이 있으면 예고 대신 지금 말하게.
const shouldNudgeActionPromise =
actionPromiseNudges < 1
&& allToolResults.length === 0
&& isActionPromiseNoTool(candidateText);
if (shouldNudgeActionPromise) {
actionPromiseNudges++;
console.log(`[v2] ACTION-PROMISE POST-CHECK: promised an action ("...${candidateText.slice(-70).trim()}") but made 0 tool calls this turn. Nudging once...`);
sendSSE('info', { message: '모델이 실행 예고만 하고 멈췄습니다 — 실제 실행을 요청합니다...' });
messages.push({ role: 'assistant', content: candidateText });
messages.push({
role: 'user',
content: 'You announced an action (search/download/create/…) but the turn ended with zero tool calls — the user only sees the promise, which reads as if you stopped mid-answer. Do it now: make the real tool call, then report the result. If you cannot or should not perform it, say so plainly right now instead of promising.',
});
lastRoundWasGuardReprompt = false;
continue;
}
const shouldForceBrowserRetry =
orchestrationSkillEnabled
&& browserContinuationPending
+22
View File
@@ -513,6 +513,28 @@ export function isDeferralPromiseReply(text: string): boolean {
return /잠(시|깐)\s*(만)?\s*(기다려|대기|후에|뒤에|만요)|기다려\s*주(세요|십시오)|곧\s*(알려|말씀|정리해|공유)|잠시\s*후\s*(다시|알려)|조금만\s*기다|hold on\b|one moment\b|give me a (moment|sec|minute)|bear with me|be right back|will (get back|come back) to you|let me get back to you/i.test(s);
}
/**
* 행동 예고("먼저 검색을 시작합니다", "찾아보겠습니다", "Let me search…")로 끝나는 답변.
*
* 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 사용자가 "필요한 파일들 다운로드해서 프로젝트를
* 채우자"라고 하자 모델이 "먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다"만 내놓고 도구
* 0개로 턴 종료 — 사용자가 "?"로 되물어서야 사과하고 또 예고만 반복. isDeferralPromiseReply는
* "잠시만 기다려" 류만 잡고, 호출부의 deferral 넛지는 isExecutionLikeRequest를 아예 제외하므로
* 이 케이스(실행형 요청 + 명시적 실행 예고)가 두 망 다 통과했다.
*
* 잡는 범위: 답변 **끝부분**이 행동 동사+미래 서약(~하겠습니다/~해 보겠습니다/~시작합니다) 또는
* 영어 let me / I'll … 로 끝난다. 호출부(handleChat)가 "이번 턴 도구 호출 0개"를 보장한다 —
* 도구를 이미 썼다면 예고 문구가 남아도 실행한 것이므로 건드리지 않는다.
*/
export function isActionPromiseNoTool(text: string): boolean {
const s = String(text || '').trim();
if (!s || s.length > 800) return false;
const tail = s.slice(-220);
return (
/(검색|찾|조사|다운로드|받아|불러|만들|생성|저장|정리|수집|모으|확인|시작|진행|시도|실행)[가-힣\s]{0,5}겠(습니다|다)|시작합니다|(?:let\s+me|i'?ll|i\s+will)\s+(?:search|find|check|look|download|fetch|grab|read|create|make|try)\b/i.test(tail)
);
}
/**
* 사용자가 던진 추측을 근거 없이 확인해 주는 답변인가.
*
+1 -1
View File
@@ -14,7 +14,7 @@ import { getConfig } from '../../config/config.js';
// "flash"-tier model chosen specifically for latency, not quality — this call is a small
// classification task (pick relevant indices from a short list), not a task worth spending
// primary-model-grade reasoning time on. Revisit if this model is ever unpulled/renamed.
const RERANK_MODEL = 'deepseek-v4-flash:cloud';
const RERANK_MODEL = 'gemma4:31b-cloud';
// Only fires on genuinely ambiguous retrieval (see the gating in personality-context.ts) —
// most turns never reach this call at all, so a generous timeout here doesn't cost anything
// on the common path. 8s was measured to occasionally clip real (successful, just slow)
+15 -8
View File
@@ -1,4 +1,4 @@
import { Express, Request, Response } from 'express';
import { Express, Request, Response, NextFunction } from 'express';
import { spawn, ChildProcess } from 'child_process';
import http from 'http';
import { getConfig } from '../../config/config.js';
@@ -201,7 +201,11 @@ export async function nvrFetch(nvrPath: string, opts: { method?: string; body?:
return r.json();
}
export function registerNvrRoutes(app: Express): void {
export function registerNvrRoutes(
app: Express,
deps: { requireGatewayAuth: (req: Request, res: Response, next: NextFunction) => void }
): void {
const { requireGatewayAuth } = deps;
app.get('/api/nvr/config', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
@@ -837,9 +841,14 @@ export function registerNvrRoutes(app: Express): void {
'FocusNear': 13, 'FocusFar': 14,
'Stop': 15,
};
app.post('/api/nvr/ptz', async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
// requireGatewayAuth: 세션 쿠키 OR gateway.auth_token(Bearer/X-Gateway-Token) OR localhost.
// 옥토프린트 오버레이(control.jinja2, 컨테이너 안에 정적 토큰으로 박혀있음)가 이 엔드포인트를
// 부르는데, 예전엔 로그인 세션 토큰을 그대로 박아놔서 7일 뒤 세션이 만료되며 PTZ 전체가
// 조용히 죽었다(2026-09-28 실측 — "틸팅이 안 되네"로 신고됐지만 실은 상하좌우 다 401이었음,
// 오버레이 JS의 fetch().catch(()=>{})가 에러를 삼켜서 겉으론 티가 안 남). auth_token은
// 안 만료되는 고정 토큰이라 이 용도(브라우저 세션이 아니라 정적 페이지에 박아두는 자격증명)에
// 맞다.
app.post('/api/nvr/ptz', requireGatewayAuth, async (req: Request, res: Response) => {
try {
const { channel, command, speed } = req.body || {};
const ch = Number(channel);
@@ -873,9 +882,7 @@ export function registerNvrRoutes(app: Express): void {
// 멈춰있으면 그 프로세스만 죽여서 go2rtc가 새로 띄우게 한다. 아무도 안 보고 있어서 프로듀서가
// 아예 없으면 아무 것도 안 함(불필요하게 인코딩을 깨우지 않음). 이미 정상 수신 중이면 그대로 둠
// (여러 명이 동시에 봐도 공유 스트림이라 매번 재시작하지 않음).
app.post('/api/nvr/mjpeg-health', async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
app.post('/api/nvr/mjpeg-health', requireGatewayAuth, async (req: Request, res: Response) => {
try {
const src = String(req.body?.src || 'cam_mjpeg').replace(/[^a-zA-Z0-9_-]/g, '');
const container = String(req.body?.container || 'go2rtc-printroom').replace(/[^a-zA-Z0-9_-]/g, '');
+13 -2
View File
@@ -2652,6 +2652,17 @@ app.use((req, _res, next) => {
return next();
}
// 밀링캠(옥토프린트 오버레이) PTZ — control.jinja2가 컨테이너 안에 정적 gateway.auth_token을
// 박아두고 브라우저에서 직접 이 도메인으로 fetch한다(로그인 세션이 없음). 예전엔 로그인 세션
// 토큰을 박아놨다가 7일 뒤 만료되어 PTZ 전체가 조용히 죽었다(2026-09-28). 이 위의 activeSessions
// 체크는 세션 토큰만 알아서 여기까지 못 왔었는데, gateway.auth_token(설정된 경우)도 유효한
// 자격증명으로 인정 — req.user는 안 심는다(진짜 로그인 사용자가 아니므로 다른 라우트의 role
// 체크에 실수로 걸리지 않게).
if (req.path === '/api/nvr/ptz' || req.path === '/api/nvr/mjpeg-health') {
const cfg = getConfig().getConfig() as any;
const configuredToken = String(getConfig().resolveSecret(cfg?.gateway?.auth_token) || '').trim();
if (configuredToken && token === configuredToken) return next();
}
// HLS 프록시: 브라우저 hls.js가 헤더 추가 불가 → 도메인 화이트리스트로 보안 유지
if (req.path === '/api/traffic/hls-proxy') return next();
// Static assets (CSS, JS, images, fonts) are public — but token check runs first above
@@ -3553,7 +3564,7 @@ async function runTaskHeartbeat(): Promise<void> {
// ── Settings routes (extracted to routes/settings.ts) ──────────────────────────
registerSettingsRoutes(app);
registerImagegenRoutes(app);
registerNvrRoutes(app);
registerNvrRoutes(app, { requireGatewayAuth });
registerK2Routes(app);
// Fetch available Ollama models (proxies Ollama /api/tags), with vision capability flag
@@ -3959,7 +3970,7 @@ function requireGatewayAuth(
}
const cfg = getConfig().getConfig() as any;
const configuredToken = String(cfg?.gateway?.auth_token || '').trim();
const configuredToken = String(getConfig().resolveSecret(cfg?.gateway?.auth_token) || '').trim();
// 2. If no token is configured, fall back to localhost-only access.
if (!configuredToken) {
+1 -1
View File
@@ -28,7 +28,7 @@ import { isGoogleNearLimit } from './google-usage';
// reactive to a 429 (see google-usage.ts). Hardcoded rather than reusing
// models.fallback: that field is a separate, more general "provider errored
// mid-turn" mechanism (see handle-chat.ts) and may be tuned independently.
const GOOGLE_FALLBACK_MODEL = 'deepseek-v4-flash:cloud';
const GOOGLE_FALLBACK_MODEL = 'gemma4:31b-cloud';
const LEGACY_BLOCKED_MODELS = new Set(['codex-davinci-002']);
const DEFAULT_OPENAI_MODEL = 'gpt-4o';
+16 -3
View File
@@ -177,9 +177,22 @@ export function formatOpenMeteoReport(input: OpenMeteoReportInput): string {
const hourlyTimes: string[] = hourlyBlock?.time || [];
if (hourlyVars.length && hourlyTimes.length) {
const shown = Math.min(hours, maxHourlyRows, hourlyTimes.length);
// Say plainly that this window starts now, so its max is never mistaken for a daily max.
out.push('', `[시간별] 지금부터 ${hours}시간 (달력상 하루가 아니라 현재 시각 기준 연속 구간)`);
// "available"은 API가 실제로 준 것과 요청한 hours 중 작은 쪽(정상적인 부족) — 기존에도 이 경우
// 헤더는 그냥 hours를 그대로 적었다(위 테스트 고정 동작, 건드리지 않음). maxHourlyRows가 진짜
// 병목일 때만 — 즉 API엔 더 있는데 우리가 표만 줄인 경우만 — 아래서 별도로 알린다.
const available = Math.min(hours, hourlyTimes.length);
const shown = Math.min(available, maxHourlyRows);
// maxHourlyRows(기본 48)가 병목이면 표에 안 보이는 뒷부분이 있다는 걸 명시해야 한다 — 예전엔
// 조용히 잘라내면서 헤더엔 "지금부터 {hours}시간"이라고 그대로 적어서, 168시간을 요청했는데
// 표는 48행(2일)에서 끊긴 걸 모델이 알 방법이 없었다. 그러면 나머지 날짜를 지어내거나(다른
// 모델은 직접 API를 다시 불러 우회), 반대로 "데이터 부족"이라고 정직하게 보고했는데 실은
// Open-Meteo엔 데이터가 있고 이 함수가 버린 것뿐이었다(실측 2026-09-27, gemini-3.5-flash-lite,
// hours=168 요청 → 48시간에서 끊김).
if (shown < available) {
out.push('', `[시간별] 지금부터 ${shown}시간만 표시(API엔 ${available}시간치가 더 있으나 표가 길어지지 않게 자름). 그 이후 날짜까지 필요하면 daily 변수(temperature_2m_max/min, precipitation_probability_max 등)를 days 파라미터와 함께 요청할 것 — 아래 [일별] 구간은 이 제한이 없음.`);
} else {
out.push('', `[시간별] 지금부터 ${hours}시간 (달력상 하루가 아니라 현재 시각 기준 연속 구간)`);
}
out.push(`단위: ${hourlyVars.map(k => `${k}=${hourlyUnits[k] || '-'}`).join(', ')}`);
out.push(['시각', ...hourlyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
let lastDate = '';
+30
View File
@@ -31,6 +31,7 @@ import {
isDisasterRequest,
isFactualThreadFollowUp,
confirmsUserGuessWithoutGrounding,
isActionPromiseNoTool,
} from '../src/gateway/guards/prompt-gates';
describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => {
@@ -503,3 +504,32 @@ describe('도달률 감사 회귀 (2026-09-04)', () => {
});
});
// ── isActionPromiseNoTool (2026-09-25) ─────────────────────────────────────
// 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서 관련 리포지토리를 찾겠습니다"만 내놓고
// 도구 0개로 턴 종료. deferral 넛지가 "잠시만 기다려"류만 잡고 실행형 요청은 제외라 두 망 다
// 통과한 케이스. 아래 실측 문구는 로그에서 그대로 가져왔다.
describe('isActionPromiseNoTool — 행동 예고 후 도구 0개 턴', () => {
test('오늘 실측 문구들', () => {
assert.equal(isActionPromiseNoTool(
'죄송합니다. 말만 하고 실제 행동(도구 호출)을 하지 않았습니다. 바로 LanderPi의 오픈소스 자료(URDF, ROS2 패키지, 설계 파일 등)를 검색해서 프로젝트 폴더에 채워 넣겠습니다.\n\n먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다.',
), true);
assert.equal(isActionPromiseNoTool('먼저 검색을 시작합니다.'), true);
assert.equal(isActionPromiseNoTool('Let me search GitHub for the repository.'), true);
assert.equal(isActionPromiseNoTool("I'll download the URDF files into the project folder."), true);
assert.equal(isActionPromiseNoTool(' STL을 컴파일해서 CAD/에 저장하겠습니다'), true);
});
test('오탐 방지: 과거형·의문형·의례적 맺음말', () => {
assert.equal(isActionPromiseNoTool('네, 방금 전 턴에 이미 저장했습니다.'), false);
assert.equal(isActionPromiseNoTool('어떤 파일을 찾아드릴까요?'), false);
assert.equal(isActionPromiseNoTool('더 필요하시면 말씀드리겠습니다.'), false);
assert.equal(isActionPromiseNoTool('검색 결과가 없습니다. 다른 키워드를 시도해 보세요.'), false);
assert.equal(isActionPromiseNoTool('다운로드는 완료됐습니다. 파일은 CAD/에 있습니다.'), false);
});
test('긴 답변(>800자)은 대상 아님 — 완성된 답을 넛지하지 않는다', () => {
const long = '정상 답변 본문. '.repeat(95) + '먼저 검색을 시작합니다.';
assert.equal(isActionPromiseNoTool(long), false);
});
});
+42
View File
@@ -264,4 +264,46 @@ describe('formatOpenMeteoReport — 베를린 실패 사례', () => {
assert.match(text, /시각\ttemperature 2m/);
assert.equal(text.split('\n').filter(l => l.startsWith('2026-')).length, 5);
});
// 2026-09-27 실측 회귀: hours=168(일주일) 요청 + API가 168시간치를 실제로 다 줬는데도
// maxHourlyRows(기본 48)에 걸려 표가 2일치에서 끊겼다. 그런데 헤더는 여전히 "지금부터 168시간"
// 이라고 적어서, 모델이 표에 없는 뒷부분을 데이터 자체가 없는 것으로 오인했다(gemini-3.5-flash-lite
// 가 "데이터 부족"이라 정직하게 보고 — 모델 잘못이 아니라 이 함수가 잘라낸 걸 숨긴 게 원인).
test('maxHourlyRows가 API 데이터보다 작으면(진짜 병목) 잘렸다고 명시한다', () => {
const times = Array.from({ length: 168 }, (_, i) => {
const d = new Date(Date.UTC(2026, 8, 27) + i * 3600_000);
return d.toISOString().slice(0, 16);
});
const text = formatOpenMeteoReport({
displayName: '구미',
timezone: 'Asia/Seoul',
utcOffsetSeconds: 9 * 3600,
hours: 168,
hourlyVars: ['temperature_2m'],
dailyVars: [],
hourlyBlock: { time: times, temperature_2m: times.map(() => 20) },
now,
});
assert.match(text, /48시간만 표시/);
assert.match(text, /168시간치가 더 있으나/);
assert.match(text, /daily 변수/);
});
// 반대로 API가 요청보다 적게 줬을 뿐(진짜 병목이 아님)이면 기존 동작(헤더에 요청 hours 그대로)을
// 유지한다 — "시간별 구간은 '달력상 하루가 아님'이 명시된다" 테스트가 이 케이스를 이미 고정한다.
test('API 데이터가 hours보다 적을 뿐이면 잘렸다는 문구를 붙이지 않는다', () => {
const times = ['2026-07-29T15:00', '2026-07-29T23:00'];
const text = formatOpenMeteoReport({
displayName: '구미',
timezone: 'Asia/Seoul',
utcOffsetSeconds: 9 * 3600,
hours: 24,
hourlyVars: ['temperature_2m'],
dailyVars: [],
hourlyBlock: { time: times, temperature_2m: times.map(() => 20) },
now,
});
assert.doesNotMatch(text, /표가 길어지지 않게/);
assert.match(text, /지금부터 24시간 \(/);
});
});
+8
View File
@@ -291,3 +291,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* 지연 로딩 모듈을 받는 동안 탭 패널을 흐리게 하고 클릭을 막는다 */
.rb-pane.rb-loading{opacity:.45;pointer-events:none;transition:opacity .15s;}
/* 라이브 카메라 패널 */
.rb-attach-btn.on{border-color:#ef4444;color:#ef4444;}
.rb-cam-panel{display:flex;align-items:center;gap:10px;padding:8px 10px;border-top:1px solid var(--line);flex-wrap:wrap;}
.rb-cam-panel video{height:96px;max-width:48%;border-radius:8px;background:#000;object-fit:cover;}
.rb-cam-ctl{display:flex;flex-direction:column;gap:6px;font-size:11px;color:var(--muted);}
.rb-cam-ctl label{display:flex;align-items:center;gap:6px;cursor:pointer;}
.rb-cam-btns{display:flex;gap:6px;flex-wrap:wrap;}
.rb-msg .rb-cam-shot{display:block;max-width:140px;border-radius:6px;margin-bottom:4px;}
+76 -8
View File
@@ -224,6 +224,20 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.dr-img-viewer-body{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:grab;}
.dr-img-viewer-body.dragging{cursor:grabbing;}
.dr-img-viewer-body img{max-width:96%;max-height:96%;object-fit:contain;transform-origin:center center;transition:transform .08s linear;user-select:none;}
/* 시진·청진 캡처 패널 */
.dr-cap-panel{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
#camera-panel.dr-cap-panel{flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px}
#camera-panel video{height:96px;max-width:48%;border-radius:8px;background:#000;object-fit:cover}
.dr-cap-ctl{display:flex;flex-direction:column;gap:6px}
.dr-cap-ctl label{display:flex;align-items:center;gap:6px;cursor:pointer}
.dr-cap-btns,.dr-cap-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.dr-cap-panel button,.dr-cap-panel select{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;color:var(--text);cursor:pointer;font-family:inherit}
.dr-cap-panel select{max-width:48%;background:var(--bg,transparent)}
.dr-cap-panel button:hover{border-color:var(--acc,#22c55e)}
.dr-cap-hint{font-size:11px;color:var(--muted);line-height:1.4}
#steth-wave{width:100%;height:56px;background:#0b0f0c;border-radius:6px}
.app-mic-btn.on{border-color:#ef4444;color:#ef4444}
.dr-msg .dr-cam-shot{display:block;max-width:140px;border-radius:6px;margin-bottom:4px}
</style>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
@@ -231,6 +245,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
<script src="../js/voice/voice-utils.js"></script>
<script src="../js/app/camera.js"></script>
<script src="../js/app/doctor-capture.js"></script>
</head>
<body>
@@ -452,11 +468,48 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<button class="dr-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
</div>
<div class="dr-msgs" id="chat-msgs"></div>
<!-- 시진: 라이브 카메라 패널 (js/app/camera.js) — 켜 두면 메시지마다 현재 화면 자동 첨부 -->
<div id="camera-panel" class="dr-cap-panel" style="display:none">
<video id="camera-video" autoplay muted playsinline></video>
<div class="dr-cap-ctl">
<label><input type="checkbox" id="camera-auto" checked> 메시지마다 현재 화면 자동 첨부</label>
<div class="dr-cap-btns">
<button onclick="cameraSnapAttach()" title="지금 화면을 입력창에 첨부">📸 촬영</button>
<button onclick="drCameraSaveToCase()" title="지금 화면을 이 기록의 자료로 저장">🗂 기록에 저장</button>
<button onclick="cameraFlip()" title="전면/후면 전환">🔄</button>
<button onclick="cameraClose()" title="카메라 끄기">✕</button>
</div>
<span id="dr-cam-status" class="dr-cap-hint"></span>
</div>
</div>
<!-- 청진: 원음 녹음 패널 (js/app/doctor-capture.js) -->
<div id="steth-panel" class="dr-cap-panel dr-steth" style="display:none">
<div class="dr-cap-row">
<select id="steth-site" title="청진 부위">
<optgroup label="심음"><option>심음-대동맥판(우2늑간)</option><option>심음-폐동맥판(좌2늑간)</option><option>심음-삼첨판(좌흉골연)</option><option>심음-승모판(심첨부)</option></optgroup>
<optgroup label="폐음"><option>폐-우상엽</option><option>폐-좌상엽</option><option>폐-우하엽</option><option>폐-좌하엽</option><option>폐-등(후면)</option></optgroup>
<optgroup label="기타"><option>경동맥</option><option>복부-장음</option><option>기타</option></optgroup>
</select>
<select id="steth-dev" onchange="stethChangeDevice(this.value)" title="입력 장치(전자청진기·청진기 마이크)"></select>
</div>
<canvas id="steth-wave" width="320" height="56"></canvas>
<div class="dr-cap-row">
<button id="steth-rec" onclick="stethRecToggle()">⏺ 녹음</button>
<span id="steth-time" class="dr-cap-hint">0:00</span>
<button id="steth-save" onclick="stethSave()" style="display:none">💾 기록에 저장</button>
<button onclick="stethClose()" title="닫기">✕</button>
</div>
<audio id="steth-audio" controls style="display:none;width:100%;height:32px"></audio>
<span id="steth-status" class="dr-cap-hint"></span>
<span class="dr-cap-hint">잡음 제거·자동 음량을 꺼서 원음 그대로 무손실(WAV)로 저장합니다. 최대 60초. 일반 폰 마이크로는 심음·호흡음이 잘 안 잡히므로 전자청진기나 청진기용 마이크를 입력 장치로 고르세요.</span>
</div>
<div class="dr-input-bar">
<textarea class="dr-textarea" id="chat-input" rows="1" placeholder="증상이나 건강 관련 질문을 입력하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<input type="file" id="file-input" accept=".pdf,.txt" style="display:none" onchange="onFilePicked(this.files[0])">
<button class="app-mic-btn" onclick="document.getElementById('file-input').click()" title="검사결과·진단서 첨부 (PDF/TXT)">📎</button>
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
<button class="app-mic-btn" id="camera-btn" onclick="toggleCamera()" title="카메라 켜기 (시진 — 메시지마다 현재 화면 첨부)">📷</button>
<button class="app-mic-btn" id="steth-btn" onclick="toggleSteth()" title="청진 녹음">🎧</button>
<button class="dr-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
@@ -557,7 +610,7 @@ async function loadHistory(){
const el=document.getElementById('chat-msgs');
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
for(const m of recent){
if(m.role==='user')addMsg('user',escHtml(m.content||''));
if(m.role==='user')addMsg('user',userMsgHtml(m.content));
else addMsg('assistant',renderMd(m.content||''));
}
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
@@ -991,9 +1044,11 @@ function fmtFileSize(n){
}
const DR_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
const DR_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
const DR_CATEGORY_ORDER=['photo','video','document','other'];
const DR_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
const DR_CATEGORY_LABELS={photo:'📷 사진',audio:'🎧 청진·오디오',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
const DR_CATEGORY_ORDER=['photo','audio','video','document','other'];
const DR_CATEGORY_ICONS={photo:'📷',audio:'🎧',video:'🎥',document:'📄',other:'📦'};
// 서버 분류(case-storage.ts)는 다른 앱과 공유라 오디오 카테고리가 없다 — 여기서 확장자로 보정
const DR_AUDIO_EXTS=/\.(wav|mp3|m4a|aac|ogg|oga|flac)$/i;
// Files are separated by which tab's upload button was actually used
// (uploadOneCaseFile prefixes with 'attachments/' or 'submissions/'), not by
@@ -1030,7 +1085,7 @@ async function loadCaseDocs(){
function renderCaseFileList(list,files){
if(!files.length){list.innerHTML='<div class="dr-cl-empty">자료를 업로드하세요.</div>';return;}
const groups={};
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
files.forEach(f=>{if((f.category||'other')==='other'&&DR_AUDIO_EXTS.test(f.name))f.category='audio';(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
list.innerHTML=DR_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
const items=groups[cat];
return `<div class="dr-ev-group-title">${DR_CATEGORY_LABELS[cat]} (${items.length})</div>`
@@ -1049,7 +1104,8 @@ function renderCaseFileRow(f){
:isImage
?`<a href="javascript:void(0)" onclick="openImageViewer('${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;display:flex;align-items:center;gap:6px;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all"><img src="${escAttr(f.url)}" alt="" style="width:28px;height:28px;object-fit:cover;border-radius:4px;flex-shrink:0">${escHtml(f.name)}</a>`
:`<a href="${escAttr(f.url)}" target="_blank" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(f.name)}</a>`;
return `<div class="dr-cl-item">
const isAudio=f.category==='audio';
return `<div class="dr-cl-item"${isAudio?' style="flex-wrap:wrap"':''}>
<span style="flex-shrink:0">${icon}</span>
${nameHtml}
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
@@ -1057,6 +1113,7 @@ function renderCaseFileRow(f){
${isCloudEditable?`<a href="/api/doctor/collabora-open?caseId=${encodeURIComponent(currentCaseId)}&relPath=${encodeURIComponent(f.relPath)}" target="_blank" class="dr-cl-del" style="opacity:.7" title="Collabora에서 바로 편집합니다. 입력하는 대로 기록 폴더에 자동 저장됩니다.">🌐</a>`:''}
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dr-cl-del" title="기기에 다운로드">💾</a>
<button class="dr-cl-del" onclick="deleteCaseFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
${isAudio?`<audio controls preload="none" src="${escAttr(f.url)}" style="width:100%;height:30px;flex-basis:100%"></audio>`:''}
</div>`;
}
@@ -1343,14 +1400,25 @@ function renderMd(text){
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
// 사용자 메시지 HTML — 카메라 프레임 마크다운(![📷 …](/api/files/…))은 썸네일로, 나머지는 이스케이프 텍스트로.
// escHtml은 ! [ ] ( ) / 를 건드리지 않으므로 이스케이프한 뒤에 치환해도 안전하다.
function userMsgHtml(content){
return escHtml(content||'').replace(/!\[📷[^\]]*\]\((\/api\/files\/[^)]+)\)\s*/g,
(m,url)=>'<img class="dr-cam-shot" src="'+url+'" alt="카메라 화면">');
}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
// 시진: 라이브 카메라(camera.js)가 켜져 있으면 지금 화면을 찍어 이 메시지에 붙인다 — 실패해도 메시지는 그대로 전송.
let cam='';
if(typeof window.cameraFrameForSend==='function'){try{cam=await window.cameraFrameForSend();}catch{cam='';}}
const full=cam?cam+'\n\n'+msg:msg;
addMsg('user',userMsgHtml(full));
await callChat(full);
}
function sendQuick(msg){
+14
View File
@@ -424,6 +424,18 @@
<div class="queued-prompts-list" id="queued-prompts-list"></div>
</div>
<!-- 첨부 파일 카드 영역 (전송 전 표시) -->
<!-- 라이브 카메라 패널: 켜 두면 메시지를 보낼 때마다 현재 프레임이 자동 첨부된다 (js/app/camera.js) -->
<div id="camera-panel" style="display:none;align-items:center;gap:10px;padding:8px 12px;border-top:1px solid var(--line);flex-wrap:wrap">
<video id="camera-video" autoplay muted playsinline style="height:110px;max-width:45vw;border-radius:8px;background:#000;object-fit:cover"></video>
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="camera-auto" checked /> 메시지마다 현재 화면 자동 첨부</label>
<div style="display:flex;gap:6px">
<button class="send-btn" onclick="cameraSnapAttach()" title="지금 화면을 입력창에 첨부" style="font-size:14px;width:auto;padding:0 10px;height:32px" aria-label="지금 촬영">📸 촬영</button>
<button class="send-btn" onclick="cameraFlip()" title="전면/후면 전환" style="font-size:14px;width:auto;padding:0 10px;height:32px" aria-label="카메라 전환">🔄 전환</button>
<button class="send-btn" onclick="cameraClose()" title="카메라 끄기" style="font-size:14px;width:32px;height:32px;background:#c0392b" aria-label="카메라 끄기">✕</button>
</div>
</div>
</div>
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
<div id="voice-call-bar" style="display:none;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--line);font-size:13px;color:var(--text-dim,#888)">
<span id="voice-call-status">통화 연결 중…</span>
@@ -437,6 +449,7 @@
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
<button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button>
<button class="send-btn" id="voice-call-btn" onclick="toggleVoiceCall()" title="실시간 음성 대화 (연속 대화 모드)" style="font-size:18px;width:44px;height:44px" aria-label="실시간 음성 대화">📞</button>
<button class="send-btn" id="camera-btn" onclick="toggleCamera()" title="카메라 켜기 (메시지마다 현재 화면 첨부)" style="font-size:18px;width:44px;height:44px" aria-label="카메라">📷</button>
<textarea
id="chat-input"
placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
@@ -2037,6 +2050,7 @@
<script src="../js/app/voice.js"></script>
<script src="../js/app/media.js"></script>
<script src="../js/app/files.js"></script>
<script src="../js/app/camera.js"></script>
<script src="../js/app/chat-sse.js"></script>
<script src="../js/app/chat-sessions.js"></script>
<script src="../js/app/chat-render.js"></script>
+14
View File
@@ -157,7 +157,20 @@
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div>
<div class="rb-msgs" id="chat-msgs"></div>
<!-- 라이브 카메라 패널: 켜 두면 메시지마다 현재 화면이 자동 첨부된다 (js/app/camera.js) -->
<div id="camera-panel" class="rb-cam-panel" style="display:none">
<video id="camera-video" autoplay muted playsinline></video>
<div class="rb-cam-ctl">
<label><input type="checkbox" id="camera-auto" checked> 메시지마다 현재 화면 자동 첨부</label>
<div class="rb-cam-btns">
<button class="rb-mini-btn" onclick="cameraSnapAttach()" title="지금 화면을 입력창에 첨부">📸 촬영</button>
<button class="rb-mini-btn" onclick="cameraFlip()" title="전면/후면 전환">🔄 전환</button>
<button class="rb-mini-btn" onclick="cameraClose()" title="카메라 끄기">✕</button>
</div>
</div>
</div>
<div class="rb-input-bar">
<button class="rb-attach-btn" id="camera-btn" onclick="toggleCamera()" title="카메라 켜기 (메시지마다 현재 화면 첨부)">🎥</button>
<div class="rb-attach-wrap">
<button class="rb-attach-btn" onclick="toggleAttachPop(event)" title="이 프로젝트의 사진을 채팅에 첨부(모델이 직접 봄)">📷</button>
<div class="rb-attach-pop" id="attach-pop"></div>
@@ -180,6 +193,7 @@
<script src="../js/app/workshop-parts.js"></script>
<script src="../js/app/workshop-tasks.js"></script>
<script src="../js/app/workshop-files.js"></script>
<script src="../js/app/camera.js"></script>
<script src="../js/app/workshop-chat.js"></script>
<script src="../js/app/workshop-init.js"></script>
</body>
+114
View File
@@ -0,0 +1,114 @@
// ── 채팅 라이브 카메라 ───────────────────────────────────────────────────
// 📷 버튼으로 카메라 패널(미리보기)을 켜 두면, 메시지를 보낼 때마다(타이핑·음성 통화 STT 공통)
// 현재 프레임을 찍어 이미지로 올리고 메시지에 자동 첨부한다. 서버는 기존 이미지 첨부와 같은
// 경로(/api/upload/image → ![alt](/api/files/..) → resolveImageContent)로 모델에 시각 입력을 넘긴다.
// 프레임 alt는 CAMERA_ALT("📷…")로 시작 — 서버가 이전 턴의 카메라 프레임을 텍스트로 접어 비용 누적을 막는다.
(function () {
var CAMERA_ALT = '📷 카메라';
var MAX_SIDE = 768; // 긴 변 기준 다운스케일 (모델 입력 비용·업로드 크기 절감)
var JPEG_QUALITY = 0.72;
var stream = null;
var facing = 'environment'; // 폰은 후면 우선, 후면이 없으면 브라우저가 알아서 대체
function $(id) { return document.getElementById(id); }
// 메인 채팅은 authHeaders(), 작업실 페이지는 authH() — 둘 다 지원
function authHdr() {
if (typeof window.authHeaders === 'function') return window.authHeaders();
if (typeof window.authH === 'function') return window.authH();
return {};
}
function insertText(md) {
if (typeof window.insertIntoChatInput === 'function') { window.insertIntoChatInput(md); return; }
var el = $('chat-input');
if (!el) return;
el.value = el.value ? el.value + '\n' + md : md;
el.dispatchEvent(new Event('input'));
el.focus();
}
function setOpenUI(open) {
var panel = $('camera-panel'), btn = $('camera-btn');
if (panel) panel.style.display = open ? 'flex' : 'none';
if (btn) { btn.classList.toggle('recording', open); btn.classList.toggle('on', open); btn.title = open ? '카메라 끄기' : '카메라 켜기 (메시지마다 현재 화면 첨부)'; }
}
async function openCamera() {
var video = $('camera-video');
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
});
} catch (e) {
alert('카메라 권한이 필요합니다: ' + e.message);
stream = null;
return;
}
video.srcObject = stream;
try { await video.play(); } catch (e) { /* autoplay 정책 — muted+playsinline이면 보통 통과 */ }
setOpenUI(true);
}
function closeCamera() {
if (stream) { try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} }
stream = null;
var video = $('camera-video');
if (video) video.srcObject = null;
setOpenUI(false);
}
window.toggleCamera = function () { if (stream) closeCamera(); else openCamera(); };
window.cameraFlip = async function () {
facing = facing === 'environment' ? 'user' : 'environment';
if (stream) { closeCamera(); await openCamera(); }
};
window.cameraClose = closeCamera;
window.cameraIsOn = function () { return !!stream; };
function captureBlob() {
var video = $('camera-video');
if (!stream || !video || !video.videoWidth) return Promise.resolve(null);
var w = video.videoWidth, h = video.videoHeight;
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
var cw = Math.round(w * scale), ch = Math.round(h * scale);
var canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
canvas.getContext('2d').drawImage(video, 0, 0, cw, ch);
return new Promise(function (resolve) { canvas.toBlob(resolve, 'image/jpeg', JPEG_QUALITY); });
}
window.cameraCaptureBlob = function () { return captureBlob(); };
// 현재 프레임을 서버에 올리고 채팅용 마크다운을 돌려준다. 실패하면 '' (메시지는 그대로 전송된다).
async function uploadFrame() {
var blob = await captureBlob();
if (!blob) return '';
var form = new FormData();
form.append('image', blob, 'camera-' + Date.now() + '.jpg');
var res = await fetch('/api/upload/image', { method: 'POST', headers: authHdr(), body: form });
var data = await res.json();
if (data && data.success && data.path) return '![' + CAMERA_ALT + '](/api/files/' + data.path + ')';
return '';
}
// 📸 버튼: 지금 화면을 입력창에 이미지로 넣는다 (자동 첨부와 별개로 수동 첨부)
window.cameraSnapAttach = async function () {
try {
var md = await uploadFrame();
if (md) insertText(md);
else alert('카메라 화면을 아직 읽을 수 없습니다. 잠시 후 다시 시도하세요.');
} catch (e) { alert('촬영 오류: ' + e.message); }
};
// sendChat이 전송 직전에 호출 — 라이브 자동 첨부가 켜져 있으면 프레임 마크다운, 아니면 ''
window.cameraFrameForSend = async function () {
var chk = $('camera-auto');
if (!stream || (chk && !chk.checked)) return '';
try { return await uploadFrame(); } catch (e) { console.warn('[camera] frame upload failed:', e.message); return ''; }
};
// 탭을 떠나면 카메라 스트림 정리 (모바일에서 카메라 표시등이 계속 켜진 채 남는 것 방지)
window.addEventListener('pagehide', closeCamera);
})();
+10 -4
View File
@@ -25,16 +25,22 @@ async function sendChat(queuedMessage = null) {
return;
}
// 라이브 카메라(camera.js): 켜져 있으면 현재 프레임을 찍어 이 메시지에 자동 첨부. 큐 재전송은 제외.
let cameraMd = '';
if (typeof queuedMessage !== 'string' && typeof window.cameraFrameForSend === 'function') {
try { cameraMd = await window.cameraFrameForSend(); } catch (e) { cameraMd = ''; }
}
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
let fullMessage = message;
let displayMessage = message;
let fullMessage = cameraMd ? cameraMd + (message ? '\n\n' + message : '') : message;
let displayMessage = fullMessage;
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
const attachParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
.map(function(a) {
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
});
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : '');
fullMessage = attachParts.join('\n\n') + (fullMessage ? '\n\n' + fullMessage : '');
const compactParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; })
.map(function(a) {
@@ -46,7 +52,7 @@ async function sendChat(queuedMessage = null) {
}
return line;
});
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : '');
displayMessage = compactParts.join('\n') + (displayMessage ? '\n\n' + displayMessage : '');
clearStagedAttachments();
}
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
+198
View File
@@ -0,0 +1,198 @@
// ── 의료탭(닥터앱) 시진·청진 캡처 ─────────────────────────────────────────────
// 시진(사진): camera.js의 라이브 카메라를 그대로 쓰고, "기록에 저장"으로 현재 화면을 환자 기록 자료에 넣는다.
// 청진(오디오): 잡음 제거·자동 음량을 끈 원음 녹음 → 무손실 WAV → 기록 자료에 저장.
// (voice-call은 echoCancellation/noiseSuppression을 켠다 — 청진에선 심음·호흡음이 "잡음"으로 깎여 나가므로 반드시 끈다)
// 의존: doctor-app.html의 currentCaseId, uploadOneCaseFile, loadCaseFiles, activeCaseTab, escHtml, addMsg, authH
(function () {
function $(id) { return document.getElementById(id); }
function pad(n) { return String(n).padStart(2, '0'); }
function stamp() {
var d = new Date();
return d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
}
function safeSeg(s) { return String(s || '').replace(/[\\/:*?"<>|\s]+/g, '_'); }
function refreshCaseFiles() {
try { if (typeof activeCaseTab !== 'undefined' && activeCaseTab === 'files') loadCaseFiles(); } catch (e) {}
}
// ── 시진: 카메라 화면을 환자 기록에 저장 ────────────────────────────────────
window.drCameraSaveToCase = async function () {
if (!currentCaseId) { alert('기록을 먼저 선택하세요.'); return; }
var blob = await window.cameraCaptureBlob();
if (!blob) { alert('카메라 화면을 아직 읽을 수 없습니다.'); return; }
var st = $('dr-cam-status');
if (st) st.textContent = '저장 중…';
try {
var file = new File([blob], '시진_' + stamp() + '.jpg', { type: 'image/jpeg' });
await uploadOneCaseFile(file, 'attachments');
if (st) { st.textContent = '✅ 기록에 저장됨'; setTimeout(function () { st.textContent = ''; }, 2500); }
refreshCaseFiles();
} catch (e) {
if (st) st.textContent = '❌ 저장 실패';
}
};
// ── 청진: 원음 녹음 ─────────────────────────────────────────────────────────
var MAX_SEC = 60;
var stream = null, ctx = null, node = null, analyser = null, src = null;
var chunks = [], recording = false, startedAt = 0, timer = null, raf = 0;
var lastBlob = null, lastUrl = null;
function setRecUI(rec) {
var btn = $('steth-rec');
if (btn) btn.textContent = rec ? '⏹ 정지' : '⏺ 녹음';
var b = $('steth-btn');
if (b) { b.classList.toggle('recording', !!stream); b.classList.toggle('on', !!stream); }
}
async function fillDevices(selectedId) {
var sel = $('steth-dev');
if (!sel || !navigator.mediaDevices.enumerateDevices) return;
var devs = (await navigator.mediaDevices.enumerateDevices()).filter(function (d) { return d.kind === 'audioinput'; });
sel.innerHTML = devs.map(function (d, i) {
return '<option value="' + escHtml(d.deviceId) + '"' + (d.deviceId === selectedId ? ' selected' : '') + '>' + escHtml(d.label || ('입력장치 ' + (i + 1))) + '</option>';
}).join('');
}
async function openMic(deviceId) {
closeMic();
// 원음: 잡음제거·에코제거·자동음량 모두 끔
var audio = { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false };
if (deviceId) audio.deviceId = { exact: deviceId };
stream = await navigator.mediaDevices.getUserMedia({ audio: audio, video: false });
ctx = new (window.AudioContext || window.webkitAudioContext)();
await ctx.audioWorklet.addModule('/js/app/steth-worklet.js');
src = ctx.createMediaStreamSource(stream);
analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
node = new AudioWorkletNode(ctx, 'steth-capture-processor');
node.port.onmessage = function (e) { if (recording) chunks.push(e.data); };
src.connect(analyser);
src.connect(node);
// node는 출력에 연결하지 않는다(자기 소리가 스피커로 되먹임되어 하울링나는 것 방지)
await fillDevices(stream.getAudioTracks()[0] && stream.getAudioTracks()[0].getSettings().deviceId);
drawWave();
setRecUI(false);
}
function closeMic() {
if (raf) cancelAnimationFrame(raf); raf = 0;
try { node && node.disconnect(); } catch (e) {}
try { src && src.disconnect(); } catch (e) {}
try { ctx && ctx.close(); } catch (e) {}
try { stream && stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
node = src = analyser = ctx = stream = null;
recording = false;
if (timer) clearInterval(timer); timer = null;
}
// 실시간 파형 — 청진기 접촉 상태·소리 크기를 눈으로 확인하는 용도
function drawWave() {
var cv = $('steth-wave');
if (!cv || !analyser) return;
var g = cv.getContext('2d');
var buf = new Uint8Array(analyser.fftSize);
(function loop() {
if (!analyser) return;
analyser.getByteTimeDomainData(buf);
g.clearRect(0, 0, cv.width, cv.height);
g.strokeStyle = recording ? '#ef4444' : '#22c55e';
g.lineWidth = 1.5;
g.beginPath();
for (var i = 0; i < buf.length; i++) {
var x = i / buf.length * cv.width;
var y = buf[i] / 255 * cv.height;
i ? g.lineTo(x, y) : g.moveTo(x, y);
}
g.stroke();
raf = requestAnimationFrame(loop);
})();
}
function encodeWav(float32Chunks, sampleRate) {
var len = 0; float32Chunks.forEach(function (c) { len += c.length; });
var buffer = new ArrayBuffer(44 + len * 2);
var v = new DataView(buffer);
function str(o, s) { for (var i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }
str(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); str(8, 'WAVE'); str(12, 'fmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, 'data'); v.setUint32(40, len * 2, true);
var o = 44;
float32Chunks.forEach(function (c) {
for (var i = 0; i < c.length; i++) {
var s = Math.max(-1, Math.min(1, c[i]));
v.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true); o += 2;
}
});
return new Blob([buffer], { type: 'audio/wav' });
}
window.toggleSteth = async function () {
var panel = $('steth-panel');
if (stream) { window.stethClose(); return; }
try {
await openMic(null);
} catch (e) {
alert('마이크 권한이 필요합니다: ' + e.message);
closeMic();
return;
}
panel.style.display = 'flex';
};
window.stethClose = function () {
closeMic();
var panel = $('steth-panel'); if (panel) panel.style.display = 'none';
setRecUI(false);
};
window.stethChangeDevice = async function (id) {
if (recording) return;
try { await openMic(id); } catch (e) { alert('장치를 열 수 없습니다: ' + e.message); }
};
window.stethRecToggle = function () {
if (!stream) return;
if (!recording) {
chunks = []; lastBlob = null;
if (lastUrl) { URL.revokeObjectURL(lastUrl); lastUrl = null; }
var au = $('steth-audio'); if (au) { au.removeAttribute('src'); au.style.display = 'none'; }
var sv = $('steth-save'); if (sv) sv.style.display = 'none';
recording = true; startedAt = Date.now();
setRecUI(true);
timer = setInterval(function () {
var sec = (Date.now() - startedAt) / 1000;
$('steth-time').textContent = Math.floor(sec / 60) + ':' + pad(Math.floor(sec % 60));
if (sec >= MAX_SEC) window.stethRecToggle();
}, 200);
} else {
recording = false;
clearInterval(timer); timer = null;
setRecUI(false);
if (!chunks.length) return;
lastBlob = encodeWav(chunks, ctx.sampleRate);
lastUrl = URL.createObjectURL(lastBlob);
var a = $('steth-audio'); a.src = lastUrl; a.style.display = '';
$('steth-save').style.display = '';
$('steth-time').textContent += ' · ' + (lastBlob.size / 1048576).toFixed(1) + 'MB';
}
};
window.stethSave = async function () {
if (!lastBlob) return;
if (!currentCaseId) { alert('기록을 먼저 선택하세요.'); return; }
var site = $('steth-site').value || '청진';
var name = '청진_' + safeSeg(site) + '_' + stamp() + '.wav';
var st = $('steth-status');
st.textContent = '저장 중…';
try {
await uploadOneCaseFile(new File([lastBlob], name, { type: 'audio/wav' }), 'attachments');
st.textContent = '✅ 저장됨: ' + name;
$('steth-save').style.display = 'none';
refreshCaseFiles();
} catch (e) { st.textContent = '❌ 저장 실패'; }
};
window.addEventListener('pagehide', function () { closeMic(); });
})();
+10
View File
@@ -0,0 +1,10 @@
// 청진 녹음용 AudioWorklet — 입력 채널 0의 원본 Float32 샘플을 그대로 메인 스레드로 넘긴다.
// (리샘플/압축 없음: 심음·호흡음 원음 보존이 목적이라 voice-call의 16kHz 캡처와 별개로 둔다)
class StethCaptureProcessor extends AudioWorkletProcessor {
process(inputs) {
const ch = inputs[0] && inputs[0][0];
if (ch && ch.length) this.port.postMessage(ch.slice(0));
return true;
}
}
registerProcessor('steth-capture-processor', StethCaptureProcessor);
+44 -3
View File
@@ -23,12 +23,19 @@ async function loadHistory(){
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
if(!msgs.length||seq!==_chatLoadSeq)return;
for(const m of msgs.slice(-30)){
if(m.role==='user')addMsg('user',escHtml(m.content||''));
if(m.role==='user')addMsg('user',userMsgHtml(m.content));
else addMsg('assistant',renderMd(m.content||''));
}
}catch{}
}
// 사용자 메시지 HTML — 카메라 프레임 마크다운(![📷 …](/api/files/…))은 썸네일로, 나머지는 이스케이프 텍스트로.
// escHtml은 ! [ ] ( ) / 를 건드리지 않으므로 이스케이프한 뒤에 치환해도 안전하다.
function userMsgHtml(content){
return escHtml(content||'').replace(/!\[📷[^\]]*\]\((\/api\/files\/[^)]+)\)\s*/g,
(m,url)=>'<img class="rb-cam-shot" src="'+url+'" alt="카메라 화면">');
}
function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='rb-msg '+role;
@@ -52,6 +59,15 @@ function renderMd(text){
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
// 링크 — [라벨](URL) → <a>. 작업실 채팅엔 링크 지원이 아예 없어 모델이 표에 넣어준
// 출처 링크(Printables 바로가기 등)가 클릭 불가 평문이었다(2026-09-25 사용자 실측).
// 이스케이프(47행) 이후에 돌리므로 URL 속 & 는 &amp; — href 속성값으로 그대로 맞다.
s=s.replace(/\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener" style="color:#f59e0b;text-decoration:underline">$1</a>');
// 남는 벌주 URL도 자동 링크. 이미 만든 <a href="..."> 안은 앞 글자가 "라 안 걸리고,
// 표/리스트 처리 전이라 셀 안에서도 살아난다.
s=s.replace(/(^|[\s>])(https?:\/\/[^\s<)"'\u0000]+)/g,(m,pre,url)=>
pre+'<a href="'+url+'" target="_blank" rel="noopener" style="color:#f59e0b;text-decoration:underline">'+url+'</a>');
// 인라인 수식 $...$ — KaTeX가 실려 있으면 삽입 직후 renderKatexIn이 진짜 수식으로
// 그려주므로 그냥 둔다(09-24: 다른 앱들은 전부 KaTeX 실려 있었는데 작업실만 빠져 있었다).
// KaTeX가 없는 환경의 폴백으로만 이 유니코드 치환을 돌린다. 자주 나오는 LaTeX 기호를
@@ -124,8 +140,12 @@ async function sendMessage(){
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
addMsg('user',escHtml(msg));
await callChat(msg);
// 라이브 카메라(camera.js)가 켜져 있으면 지금 화면을 찍어 이 메시지에 붙인다 — 실패해도 메시지는 그대로 전송.
let cam='';
if(typeof window.cameraFrameForSend==='function'){try{cam=await window.cameraFrameForSend();}catch{cam='';}}
const full=cam?cam+'\n\n'+msg:msg;
addMsg('user',userMsgHtml(full));
await callChat(full);
}
function setProjectControlsBusy(busy){
@@ -205,7 +225,11 @@ async function toggleAttachPop(ev){
const d=await r.json();
photos=(d.files||[]).filter(f=>f.category==='photo').slice(-30).reverse();
}catch{}
const camOn=typeof window.cameraIsOn==='function'&&window.cameraIsOn();
pop.innerHTML='<label class="rb-mini-btn" style="display:block;text-align:center;cursor:pointer">+ 새 사진 올려서 첨부<input type="file" accept="image/*" style="display:none" onchange="attachUpload(event)"></label>'
+(camOn
?'<button class="rb-mini-btn" style="display:block;width:100%;margin-top:6px" onclick="saveCameraShot()">🎥 지금 카메라 화면을 사진으로 저장·첨부</button>'
:'<div class="rb-empty" style="margin-top:6px">🎥로 카메라를 켜면 여기서 바로 찍어 저장할 수 있습니다.</div>')
+(photos.length
?'<div class="rb-attach-grid">'+photos.map(f=>'<img src="'+escAttr(f.url)+'" title="'+escAttr(f.relPath)+'" loading="lazy" onclick="attachPhoto(\''+escJs(f.relPath)+'\')">').join('')+'</div>'
:'<div class="rb-empty">이 프로젝트에 올린 사진이 없습니다.</div>');
@@ -216,6 +240,23 @@ function attachPhoto(relPath){
autoResize(inp);inp.focus();
document.getElementById('attach-pop').classList.remove('open');
}
// 카메라 현재 화면을 이 프로젝트의 사진 폴더에 저장하고 채팅에 첨부한다 (수동 촬영 = 기록용).
async function saveCameraShot(){
const proj=currentProject();
const pop=document.getElementById('attach-pop');
if(!proj)return;
const blob=await window.cameraCaptureBlob();
if(!blob){pop.innerHTML='<div class="rb-empty">카메라 화면을 아직 읽을 수 없습니다.</div>';return;}
pop.innerHTML='<div class="rb-empty">저장 중…</div>';
try{
const fd=new FormData();fd.append('file',blob,'camera-'+Date.now()+'.jpg');
const r=await fetch('/api/workshop/upload?projectId='+encodeURIComponent(proj.id)+'&folder='+encodeURIComponent('사진'),{method:'POST',headers:authH(),body:fd});
const d=await r.json().catch(()=>({}));
if(!r.ok||!d.relPath)throw new Error(d.error||'저장 실패');
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
attachPhoto(d.relPath);
}catch(e){pop.innerHTML='<div class="rb-empty">저장 실패: '+escHtml(e.message)+'</div>';}
}
async function attachUpload(ev){
const file=ev.target.files&&ev.target.files[0];
ev.target.value='';
+4
View File
@@ -45,6 +45,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
super();
this.queue = []; // array of Float32Array
this.qi = 0; // read offset within queue[0]
this.playing = false; // 실제로 소리를 내보내는 중인지 — 메인 스레드가 "말하기 끝"을 정확히 알 수 있게 신호를 보낸다
this.port.onmessage = (e) => {
const msg = e.data;
if (msg.type === 'push') {
@@ -55,6 +56,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
} else if (msg.type === 'clear') {
this.queue = [];
this.qi = 0;
if (this.playing) { this.playing = false; this.port.postMessage({ type: 'idle' }); }
}
};
}
@@ -63,6 +65,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
const out = outputs[0][0];
if (!out) return true;
let oi = 0;
if (!this.playing && this.queue.length > 0) { this.playing = true; this.port.postMessage({ type: 'active' }); }
while (oi < out.length) {
if (this.queue.length === 0) {
out[oi++] = 0;
@@ -75,6 +78,7 @@ class PcmPlayerProcessor extends AudioWorkletProcessor {
this.qi = 0;
}
}
if (this.playing && this.queue.length === 0) { this.playing = false; this.port.postMessage({ type: 'idle' }); }
return true;
}
}
+73 -21
View File
@@ -15,6 +15,12 @@
// poisons the whole GPU context) on very short standalone text — merge short
// sentence fragments together before sending them off as one TTS request.
const MIN_TTS_CHARS = 15;
// 말하는 동안(assistant_speaking)엔 스피커 소리가 마이크로 새어 들어오므로 끼어들기 기준을 일반 발화보다
// 훨씬 엄격하게 둔다(더 크고 더 오래). 재생이 끝난 직후에도 잔향 유예 시간 동안은 발화 감지를 막는다.
// (09-26 "말이 군데군데 끊어진다": 자기 목소리가 끼어들기로 잡혀 재생이 끊기고 남은 문장이 사라졌다)
const BARGE_RMS_ON = 0.06;
const BARGE_CONFIRM_CHUNKS = 5; // ~500ms 지속
const POST_PLAYBACK_GRACE_MS = 500;
let ws = null;
let captureCtx = null, playCtx = null;
@@ -29,8 +35,16 @@
let currentTtsId = null;
let spokenUpTo = 0;
let pendingTts = '';
// 이번 턴에 이미 읽은(대기열에 넣은) 문장의 정규화 키. 최종 답변(finalAnswer)이 토큰 스트림 뒤에
// 통째로 다시 들어오거나 가드가 답변을 다시 쓰게 해도, 같은 문장을 두 번 읽지 않는다.
// (09-26 "말을 계속 반복한다": _voiceCallResetSpoken이 읽은 위치를 0으로 되돌려 처음부터 재낭독됐다)
const spokenKeys = new Set();
const MIN_DEDUP_KEY_CHARS = 6; // "네." 같은 짧은 추임새는 반복돼도 자연스러우니 제외
function sentenceKey(t) { return String(t || '').replace(/[^\p{L}\p{N}]/gu, '').toLowerCase(); }
let active = false;
let wakeLock = null;
let playbackActive = false; // 플레이어 워클릿이 실제로 소리를 내는 중인지 (active/idle 신호)
let graceUntil = 0;
// The Wake Lock is auto-released whenever the tab loses visibility (screen off,
// app-switch) — without it, mobile browsers throttle/suspend the background tab
@@ -69,7 +83,10 @@
if (gainNode) gainNode.gain.value = vol;
};
window.voiceCallHangup = function () {
window.voiceCallHangup = function (reason) {
// 종료 사유를 콘솔에 남긴다 — 사용자가 누른 건지, 서버/네트워크가 끊은 건지 구분하기 위함
// (09-26 "대답하다가 끊어졌다" 신고: 엔진 로그만으로는 어느 쪽인지 알 수 없었다)
if (active) console.info('[voice-call] hangup:', typeof reason === 'string' ? reason : 'user/button', 'state=' + state);
active = false;
state = 'idle';
try { ws && ws.close(); } catch {}
@@ -81,6 +98,7 @@
try { wakeLock && wakeLock.release(); } catch {}
wakeLock = null;
ttsQueue = []; ttsBusy = false; currentTtsId = null;
playbackActive = false; graceUntil = 0;
const bar = document.getElementById('voice-call-bar');
if (bar) bar.style.display = 'none';
const btn = document.getElementById('voice-call-btn');
@@ -105,18 +123,26 @@
setCaption('');
state = 'listening';
speechChunks = 0; silenceMs = 0; preRoll = [];
spokenUpTo = 0; pendingTts = ''; ttsQueue = []; ttsBusy = false; currentTtsId = null;
spokenUpTo = 0; pendingTts = ''; spokenKeys.clear(); ttsQueue = []; ttsBusy = false; currentTtsId = null;
// 설정 단계(워클릿 로드·WebSocket) 어디서든 실패하면 버튼이 📵에 갇혀 "연결 중…"으로 멈추던 문제 —
// (2026-09-26 실측: 워클릿 상대경로가 HTML을 돌려줘 addModule이 예외를 던졌는데 아무 안내가 없었다)
try {
captureCtx = new (window.AudioContext || window.webkitAudioContext)();
await captureCtx.audioWorklet.addModule('voice-call-worklets.js');
await captureCtx.audioWorklet.addModule('/js/voice/voice-call-worklets.js');
const source = captureCtx.createMediaStreamSource(micStream);
captureNode = new AudioWorkletNode(captureCtx, 'mic-capture-processor');
captureNode.port.onmessage = handleMicChunk;
source.connect(captureNode);
playCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
await playCtx.audioWorklet.addModule('voice-call-worklets.js');
await playCtx.audioWorklet.addModule('/js/voice/voice-call-worklets.js');
playerNode = new AudioWorkletNode(playCtx, 'pcm-player-processor');
playerNode.port.onmessage = (e) => {
const t = e.data && e.data.type;
if (t === 'active') playbackActive = true;
else if (t === 'idle') { playbackActive = false; settleSpeaking(); }
};
// A GainNode we control from the in-app slider — on some mobile browsers
// the hardware volume rocker maps to the call/mic audio session while a
// getUserMedia stream is open, not to this Web Audio output, so the phone's
@@ -135,8 +161,24 @@
ws.binaryType = 'arraybuffer';
ws.onopen = () => setStatus('듣는 중…');
ws.onmessage = handleWsMessage;
ws.onerror = () => setStatus('연결 오류');
ws.onclose = () => { if (active) voiceCallHangup(); };
ws.onerror = (ev) => { console.warn('[voice-call] ws error', ev && ev.type); setStatus('연결 오류'); };
ws.onclose = (ev) => { if (active) voiceCallHangup('ws closed code=' + (ev && ev.code) + ' reason=' + (ev && ev.reason || '-') + ' clean=' + (ev && ev.wasClean)); };
} catch (e) {
console.error('[voice-call] start failed:', e);
voiceCallHangup();
alert('음성 통화를 시작하지 못했습니다: ' + (e && e.message ? e.message : e));
}
}
// "말하는 중"에서 "듣는 중"으로 돌아가도 되는 조건: 합성 요청도 대기열도 없고 소리 재생도 끝났을 때.
// (예전엔 마지막 문장의 합성이 끝나는 순간 바로 돌아가 아직 몇 초 재생 중인 자기 목소리를 사용자 발화로 들었다)
function settleSpeaking() {
if (state !== 'assistant_speaking') return;
if (ttsBusy || ttsQueue.length > 0 || playbackActive) return;
state = 'listening';
setStatus('듣는 중…');
graceUntil = Date.now() + POST_PLAYBACK_GRACE_MS;
speechChunks = 0;
}
function handleMicChunk(e) {
@@ -146,9 +188,14 @@
if (state === 'listening' || state === 'assistant_speaking') {
preRoll.push(pcm);
if (preRoll.length > PREROLL_CHUNKS) preRoll.shift();
if (rms > RMS_SPEECH_ON) {
const speaking = state === 'assistant_speaking';
const on = speaking ? BARGE_RMS_ON : RMS_SPEECH_ON;
const need = speaking ? BARGE_CONFIRM_CHUNKS : SPEECH_CONFIRM_CHUNKS;
if (Date.now() < graceUntil) {
speechChunks = 0;
} else if (rms > on) {
speechChunks++;
if (speechChunks >= SPEECH_CONFIRM_CHUNKS) onSpeechStart();
if (speechChunks >= need) onSpeechStart();
} else {
speechChunks = 0;
}
@@ -168,6 +215,7 @@
// barge-in: cut playback immediately and cancel whatever is still generating
ttsQueue = [];
if (playerNode) playerNode.port.postMessage({ type: 'clear' });
playbackActive = false;
if (ws && ws.readyState === WebSocket.OPEN && currentTtsId) {
ws.send(JSON.stringify({ type: 'tts_cancel', id: currentTtsId }));
}
@@ -210,7 +258,7 @@
setCaption('');
const text = (msg.text || '').trim();
if (!text) { state = 'listening'; setStatus('듣는 중…'); break; }
spokenUpTo = 0; pendingTts = '';
spokenUpTo = 0; pendingTts = ''; spokenKeys.clear();
const input = document.getElementById('chat-input');
if (input) {
input.value = text;
@@ -235,10 +283,7 @@
ttsBusy = false;
currentTtsId = null;
pumpTtsQueue();
if (ttsQueue.length === 0) {
state = 'listening';
setStatus('듣는 중…');
}
settleSpeaking();
break;
case 'error':
@@ -250,10 +295,7 @@
ttsBusy = false;
currentTtsId = null;
pumpTtsQueue();
if (ttsQueue.length === 0) {
state = 'listening';
setStatus('듣는 중…');
}
settleSpeaking();
}
break;
}
@@ -297,12 +339,14 @@
const groups = [];
let rem = n;
while (rem > 0) { groups.push(rem % 10000); rem = Math.floor(rem / 10000); }
let result = '';
// 만/억/조 단위는 띄어서 읽고 "일만"이 아니라 "만"으로 읽는다(자연스러운 한국어이기도 하고, OmniVoice가
// "일만이천구백원"을 "맛 구백원"처럼 발음해 "일만 이천"이 통째로 사라졌다 — STT 왕복 실측, 09-26)
const parts = [];
for (let g = groups.length - 1; g >= 0; g--) {
if (groups[g] === 0) continue;
result += fourDigit(groups[g]) + bigUnits[g];
parts.push((g > 0 && groups[g] === 1 ? '' : fourDigit(groups[g])) + bigUnits[g]);
}
return result || '영';
return parts.join(' ') || '영';
}
// "27.7" -> "이십칠점칠" (decimal digits read one at a time, standard
@@ -331,6 +375,9 @@
function sanitizeForSpeech(text) {
return text
// 천 단위 쉼표(9,500 / 1,234,567)는 숫자 낭독 전에 제거한다. 안 그러면 "9"와 "500"이 따로 읽혀
// "9,500원"이 "구, 오백원"이 되어 "천"이 통째로 사라진다(09-26 "단어가 한번씩 빠진다", 표의 가격 열).
.replace(/(?<=\d),(?=\d{3}(?!\d))/g, '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
@@ -426,6 +473,11 @@
// silently absorbed the next bullet line and the pause between them
// vanished (reported 2026-07-10).
function bufferSentence(sentence, isLineBreak) {
const key = sentenceKey(sentence);
if (key.length >= MIN_DEDUP_KEY_CHARS) {
if (spokenKeys.has(key)) return;
spokenKeys.add(key);
}
if (isLineBreak) {
flushPendingTts();
enqueueSentence(sentence);
@@ -463,7 +515,7 @@
// gone by the time this piece reaches it. Put an explicit period
// back so list items / paragraph breaks still read as a pause
// instead of running straight into the next line (2026-07-10).
if (piece && boundaryChar === '\n' && !/[.!?,:;]$/.test(piece)) {
if (piece && boundaryChar === '\n' && !/[.!?,:;|]$/.test(piece)) {
piece += '.';
}
if (piece) sentences.push({ text: piece, isLineBreak: boundaryChar === '\n' });