Compare commits
10
Commits
a61086d93c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724a04dbac | ||
|
|
54461fd26b | ||
|
|
85254e5f0d | ||
|
|
1a32803ef5 | ||
|
|
7c7ca45e44 | ||
|
|
57cf65fd7e | ||
|
|
cfcd2aa829 | ||
|
|
6626132eaf | ||
|
|
03a462d72e | ||
|
|
b4f9a3df9f |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 던진 추측을 근거 없이 확인해 주는 답변인가.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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시간 \(/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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 — 카메라 프레임 마크다운()은 썸네일로, 나머지는 이스케이프 텍스트로.
|
||||
// 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){
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// ── 채팅 라이브 카메라 ───────────────────────────────────────────────────
|
||||
// 📷 버튼으로 카메라 패널(미리보기)을 켜 두면, 메시지를 보낼 때마다(타이핑·음성 통화 STT 공통)
|
||||
// 현재 프레임을 찍어 이미지로 올리고 메시지에 자동 첨부한다. 서버는 기존 이미지 첨부와 같은
|
||||
// 경로(/api/upload/image →  → 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 '';
|
||||
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);
|
||||
})();
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(); });
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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 — 카메라 프레임 마크다운()은 썸네일로, 나머지는 이스케이프 텍스트로.
|
||||
// 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 속 & 는 & — 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='';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user