feat: 탐정앱 증거자료 날짜 인식 + 타임라인 자동 등록 기능
사진/텍스트 문서에서 날짜를 인식(EXIF, 비전 LLM, 텍스트 LLM)해 타임라인에 등록하는 기능 추가 — 폴더별 일괄 인식, 이름 변경 + 원본 백업, 증거 파일 삭제 연동까지 포함. 코드 리뷰로 발견된 XSS, 동시 저장 데이터 유실, 날짜 접두사 중복, GIF 미지원 등 다수 버그 수정도 포함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* routes-detective-dates.ts
|
||||||
|
* Detective app: recognize the date shown inside an evidence photo (EXIF
|
||||||
|
* DateTimeOriginal first, falling back to a vision-LLM read of any visible
|
||||||
|
* on-screen date — chat timestamps, receipt dates, etc.) and store it as a
|
||||||
|
* pending suggestion on the case for the user to approve into the timeline.
|
||||||
|
*/
|
||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import iconv from 'iconv-lite';
|
||||||
|
import { getUserWorkspace } from '../config/config';
|
||||||
|
import { getOllamaClient } from '../agents/ollama-client';
|
||||||
|
|
||||||
|
const PHOTO_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'tiff']);
|
||||||
|
const TEXT_DOC_EXTS = new Set(['txt', 'md', 'csv']);
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const MAX_TEXT_DOC_BYTES = 5 * 1024 * 1024; // reading is synchronous; cap so a huge file can't block the event loop
|
||||||
|
|
||||||
|
function extOf(relPath: string): string {
|
||||||
|
return path.extname(relPath).slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regex alone can't catch "2025-13-45" (bad month/day) — round-trip
|
||||||
|
// through Date and check the parts survived, which a real calendar date does
|
||||||
|
// and a bogus one (invalid month/day, or JS's day-rollover) does not.
|
||||||
|
function isValidCalendarDate(date: string): boolean {
|
||||||
|
if (!DATE_RE.test(date)) return false;
|
||||||
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
|
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||||
|
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInsideDir(base: string, target: string): boolean {
|
||||||
|
const resolvedBase = path.resolve(base);
|
||||||
|
const resolvedTarget = path.resolve(target);
|
||||||
|
if (resolvedBase === resolvedTarget) return true;
|
||||||
|
const rel = path.relative(resolvedBase, resolvedTarget);
|
||||||
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectiveFilesDir(username: string, caseId: string): string {
|
||||||
|
return path.join(getUserWorkspace(username), 'detective', caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectiveCasesPath(username: string): string {
|
||||||
|
return path.join(getUserWorkspace(username), '.smallclaw', 'detective-cases.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExifDate(imgPath: string): Promise<string | undefined> {
|
||||||
|
const script = `
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
import pi_heif; pi_heif.register_heif_opener()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from PIL import Image, ExifTags
|
||||||
|
img = Image.open(${JSON.stringify(imgPath)})
|
||||||
|
raw = img._getexif() or {}
|
||||||
|
exif = {ExifTags.TAGS.get(k, str(k)): v for k, v in raw.items() if k in ExifTags.TAGS}
|
||||||
|
dt = exif.get("DateTimeOriginal") or exif.get("DateTime")
|
||||||
|
print(json.dumps({"dt": str(dt) if dt else None}))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"dt": None, "error": str(e)}))
|
||||||
|
`;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let out = '';
|
||||||
|
try {
|
||||||
|
const proc = spawn('python3', ['-c', script]);
|
||||||
|
proc.stdout.on('data', (d: Buffer) => { out += d.toString(); });
|
||||||
|
proc.on('close', () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(out.trim().split('\n').pop() || '{}');
|
||||||
|
const m = parsed.dt ? String(parsed.dt).match(/^(\d{4})[:-](\d{2})[:-](\d{2})/) : null;
|
||||||
|
resolve(m ? `${m[1]}-${m[2]}-${m[3]}` : undefined);
|
||||||
|
} catch { resolve(undefined); }
|
||||||
|
});
|
||||||
|
proc.on('error', () => resolve(undefined));
|
||||||
|
} catch { resolve(undefined); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_DAY_RE = /^\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
// KakaoTalk chat lists show "8월 15일" style dates with no year anywhere, so
|
||||||
|
// forcing the model to answer a full YYYY-MM-DD often produces a false null.
|
||||||
|
// month_day lets it report what it actually saw; the caller combines that
|
||||||
|
// with the file's EXIF year as a best guess, which the user can still edit.
|
||||||
|
// The same call also asks for a short content label (used to rename the file
|
||||||
|
// when its original name is a generic camera/screenshot name) — for maps,
|
||||||
|
// the model is told to name the pinned/marked location instead.
|
||||||
|
async function readVisionDate(imgPath: string): Promise<{ date?: string; monthDay?: string; reason?: string; label?: string }> {
|
||||||
|
const ext = path.extname(imgPath).slice(1).toLowerCase();
|
||||||
|
const mime = ext === 'jpg' ? 'jpeg' : ext;
|
||||||
|
const b64 = fs.readFileSync(imgPath).toString('base64');
|
||||||
|
const prompt = '이 이미지는 사건 증거자료(카카오톡 대화 캡처, 문자메시지, 영수증, 지도, 문서 사진 등)입니다. '
|
||||||
|
+ '이미지 안에 실제로 보이는 날짜(대화창 날짜 구분선, 메시지 타임스탬프, 영수증/문서에 인쇄된 날짜 등)를 찾고, '
|
||||||
|
+ '내용을 요약하는 짧은 한국어 라벨도 함께 만들어 다음 JSON 형식으로만 답하세요 (코드블록 없이 순수 JSON만): '
|
||||||
|
+ '{"date":"연도까지 정확히 보이면 YYYY-MM-DD, 아니면 null",'
|
||||||
|
+ '"month_day":"연도는 안 보이지만 월/일은 보이면 MM-DD, 아니면 null",'
|
||||||
|
+ '"reason":"어디서 어떤 날짜를 읽었는지 한 줄 설명",'
|
||||||
|
+ '"label":"이미지 내용을 요약하는 2~6단어 내외의 짧은 한국어 표현 (예: 카톡_엄마와의_대화, 영수증_스타벅스, 문자_협박메시지). 지도 화면이고 위치 마커/핀이 있으면 마커가 가리키는 장소명을 반드시 포함 (예: 지도_강남역_인근)"} '
|
||||||
|
+ '카카오톡 채팅 목록처럼 연도 없이 "8월 15일"만 보이는 경우 date는 null, month_day는 "08-15"로 답하세요. '
|
||||||
|
+ '날짜를 아무것도 찾을 수 없으면 date, month_day 둘 다 null로 답하되 label은 항상 채우세요.';
|
||||||
|
|
||||||
|
// Never throws — a timeout/network error from the cloud model must not take
|
||||||
|
// down the whole suggestion (readExifDate runs alongside this in
|
||||||
|
// Promise.all, and an EXIF-only fallback is still useful even when vision
|
||||||
|
// is unavailable).
|
||||||
|
try {
|
||||||
|
const ollama = getOllamaClient();
|
||||||
|
const result = await ollama.chatWithThinking(
|
||||||
|
[{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
{ type: 'image_url', image_url: { url: `data:image/${mime};base64,${b64}` } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
'executor',
|
||||||
|
{ model: 'kimi-k2.6:cloud', num_predict: 1500, temperature: 0.1 },
|
||||||
|
);
|
||||||
|
const raw = String(result.message?.content || '').trim();
|
||||||
|
const m = raw.match(/\{[\s\S]*\}/);
|
||||||
|
const parsed = JSON.parse(m ? m[0] : raw);
|
||||||
|
const date = parsed?.date && isValidCalendarDate(parsed.date) ? parsed.date : undefined;
|
||||||
|
const monthDay = parsed?.month_day && MONTH_DAY_RE.test(parsed.month_day) ? parsed.month_day : undefined;
|
||||||
|
const label = parsed?.label ? String(parsed.label).trim().slice(0, 60) : undefined;
|
||||||
|
return { date, monthDay, reason: parsed?.reason ? String(parsed.reason) : undefined, label };
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old Korean text evidence is often EUC-KR, not UTF-8 (same issue the
|
||||||
|
// built-in text editor in detective-app.html handles client-side). Only the
|
||||||
|
// first MAX_TEXT_DOC_BYTES are ever read — readFileSync is synchronous and a
|
||||||
|
// huge accidental upload would otherwise block the event loop just to have
|
||||||
|
// its content truncated to 6000 chars a moment later anyway.
|
||||||
|
function decodeTextFile(filePath: string): string {
|
||||||
|
const size = fs.statSync(filePath).size;
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
const len = Math.min(size, MAX_TEXT_DOC_BYTES);
|
||||||
|
buf = Buffer.alloc(len);
|
||||||
|
fs.readSync(fd, buf, 0, len, 0);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const s = buf.toString('utf-8');
|
||||||
|
if (!s.includes('�')) return s;
|
||||||
|
} catch {}
|
||||||
|
try { return iconv.decode(buf, 'euc-kr'); } catch { return buf.toString('utf-8'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text documents have no EXIF and nothing to "look at", so this reads the
|
||||||
|
// actual content and asks the model to find a date mentioned in the text
|
||||||
|
// (a letter/report date, a diary entry, an invoice date, etc.) the same way
|
||||||
|
// readVisionDate does for photos — same JSON contract, same date/month_day/
|
||||||
|
// reason/label fields, so the rest of the pipeline doesn't need to care
|
||||||
|
// whether a suggestion came from a photo or a document.
|
||||||
|
async function readTextDate(filePath: string): Promise<{ date?: string; monthDay?: string; reason?: string; label?: string }> {
|
||||||
|
const content = decodeTextFile(filePath).slice(0, 6000);
|
||||||
|
const prompt = '이 텍스트는 사건 증거자료 문서입니다. 내용 중에 실제로 언급된 날짜(작성일, 사건 발생일, 일기/메모의 날짜 등)를 찾고, '
|
||||||
|
+ '내용을 요약하는 짧은 한국어 라벨도 함께 만들어 다음 JSON 형식으로만 답하세요 (코드블록 없이 순수 JSON만): '
|
||||||
|
+ '{"date":"연도까지 정확히 확인되면 YYYY-MM-DD, 아니면 null",'
|
||||||
|
+ '"month_day":"연도는 불명확하지만 월/일은 확인되면 MM-DD, 아니면 null",'
|
||||||
|
+ '"reason":"어떤 문구에서 날짜를 읽었는지 한 줄 설명",'
|
||||||
|
+ '"label":"문서 내용을 요약하는 2~6단어 내외의 짧은 한국어 표현 (예: 메모_병원방문기록, 진술서_초안)"} '
|
||||||
|
+ '날짜를 아무것도 찾을 수 없으면 date, month_day 둘 다 null로 답하되 label은 항상 채우세요.\n\n--- 문서 내용 ---\n' + content;
|
||||||
|
|
||||||
|
// Never throws — see readVisionDate's comment on the same pattern.
|
||||||
|
try {
|
||||||
|
const ollama = getOllamaClient();
|
||||||
|
const result = await ollama.chatWithThinking(
|
||||||
|
[{ role: 'user', content: prompt }],
|
||||||
|
'executor',
|
||||||
|
{ model: 'kimi-k2.6:cloud', num_predict: 1500, temperature: 0.1 },
|
||||||
|
);
|
||||||
|
const raw = String(result.message?.content || '').trim();
|
||||||
|
const m = raw.match(/\{[\s\S]*\}/);
|
||||||
|
const parsed = JSON.parse(m ? m[0] : raw);
|
||||||
|
const date = parsed?.date && isValidCalendarDate(parsed.date) ? parsed.date : undefined;
|
||||||
|
const monthDay = parsed?.month_day && MONTH_DAY_RE.test(parsed.month_day) ? parsed.month_day : undefined;
|
||||||
|
const label = parsed?.label ? String(parsed.label).trim().slice(0, 60) : undefined;
|
||||||
|
return { date, monthDay, reason: parsed?.reason ? String(parsed.reason) : undefined, label };
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic auto-generated camera/screenshot names carry no information, so the
|
||||||
|
// content label is used instead when renaming. A name the user (or the phone)
|
||||||
|
// already gave meaning to — like "엄마 8-7.jpg" — is left untouched.
|
||||||
|
function isGenericFileName(stem: string): boolean {
|
||||||
|
const s = stem.trim();
|
||||||
|
if (!s) return true;
|
||||||
|
const compact = s.replace(/[\s_\-.]+/g, '');
|
||||||
|
if (/^\d+$/.test(compact)) return true; // pure timestamp/number
|
||||||
|
if (/^[0-9a-f]{6,}$/i.test(compact)) return true; // hash-like
|
||||||
|
const genericPrefixes = /^(img|image|photo|pic|dsc|dcim|pxl|vid|video|mov|screenshot|scrshot|kakaotalk|kakao|scr|frame|snapshot|capture|save|received|inboundshare|스크린샷|캡처|사진|카카오톡)/i;
|
||||||
|
return genericPrefixes.test(compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileNameSegment(seg: string): string {
|
||||||
|
const s = String(seg).replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, '_').trim();
|
||||||
|
if (!s || s === '.' || s === '..') return '_';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file already renamed by this pipeline starts with "YYYY-MM-DD_" (or "-",
|
||||||
|
// in case it was ever produced by a manually-typed name). Re-running date
|
||||||
|
// recognition/commit on it — e.g. the user re-triggers 🕐+ on an already
|
||||||
|
// processed file — must not stack another date prefix on top of the old one.
|
||||||
|
// The regex alone can't tell a pipeline-generated prefix apart from a user's
|
||||||
|
// own "2024-01-15-영수증.jpg"-style name, so it's only trusted when `relPath`
|
||||||
|
// is already a relPath the pipeline itself recorded (an existing timeline
|
||||||
|
// entry or a still-pending suggestion) — otherwise the name is left whole.
|
||||||
|
const EXISTING_DATE_PREFIX_RE = /^\d{4}-\d{2}-\d{2}[_-](.+)$/;
|
||||||
|
function stripExistingDatePrefix(stem: string, relPath: string, c: any): string {
|
||||||
|
const m = stem.match(EXISTING_DATE_PREFIX_RE);
|
||||||
|
if (!m) return stem;
|
||||||
|
const knownToUs = Array.isArray(c?.timeline) && c.timeline.some((t: any) => t.relPath === relPath)
|
||||||
|
|| Array.isArray(c?.dateSuggestions) && c.dateSuggestions.some((s: any) => s.relPath === relPath);
|
||||||
|
return knownToUs ? m[1] : stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DateSuggestion {
|
||||||
|
id: string;
|
||||||
|
relPath: string;
|
||||||
|
fileName: string;
|
||||||
|
url: string;
|
||||||
|
kind: 'photo' | 'text';
|
||||||
|
date: string | null;
|
||||||
|
source: 'exif' | 'vision' | 'vision+exif_year' | 'text';
|
||||||
|
reason: string | null;
|
||||||
|
label: string | null;
|
||||||
|
nameOverride: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCasesStore(casesPath: string): { cases: any[] } {
|
||||||
|
let store: { cases: any[] } = { cases: [] };
|
||||||
|
try { if (fs.existsSync(casesPath)) store = JSON.parse(fs.readFileSync(casesPath, 'utf-8')); } catch {}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestPhotoDate(username: string, caseId: string, relPath: string): Promise<DateSuggestion> {
|
||||||
|
const dir = detectiveFilesDir(username, caseId);
|
||||||
|
const segments = relPath.split('/').filter(Boolean);
|
||||||
|
const absPath = path.join(dir, ...segments);
|
||||||
|
if (!isPathInsideDir(dir, absPath) || !fs.existsSync(absPath)) throw new Error('file not found');
|
||||||
|
const isText = TEXT_DOC_EXTS.has(extOf(relPath));
|
||||||
|
const caseAtStart = (readCasesStore(detectiveCasesPath(username)).cases || []).find((x: any) => x.id === caseId);
|
||||||
|
|
||||||
|
let date: string | undefined;
|
||||||
|
let source: 'exif' | 'vision' | 'vision+exif_year' | 'text' = isText ? 'text' : 'vision';
|
||||||
|
let reason: string | undefined;
|
||||||
|
let contentLabel: string | undefined;
|
||||||
|
|
||||||
|
if (isText) {
|
||||||
|
const text = await readTextDate(absPath);
|
||||||
|
date = text.date;
|
||||||
|
reason = text.reason;
|
||||||
|
contentLabel = text.label;
|
||||||
|
// Text documents rarely have a year-less date the way KakaoTalk chat
|
||||||
|
// lists do, and there's no EXIF to guess a year from, so month_day alone
|
||||||
|
// isn't used here — it just falls through to no date if that's all there is.
|
||||||
|
} else {
|
||||||
|
// Vision first: for chat/message screenshots the file's own EXIF timestamp
|
||||||
|
// is when the screenshot was saved, not when the conversation happened, so
|
||||||
|
// the date printed inside the image is the one that actually matters. EXIF
|
||||||
|
// is only used as a fallback (plain photos with no date text) or to guess
|
||||||
|
// the year when the screenshot shows month/day but no year (KakaoTalk never
|
||||||
|
// shows the year in its chat list).
|
||||||
|
const [vision, exifDate] = await Promise.all([readVisionDate(absPath), readExifDate(absPath)]);
|
||||||
|
contentLabel = vision.label;
|
||||||
|
if (vision.date) {
|
||||||
|
date = vision.date;
|
||||||
|
reason = vision.reason;
|
||||||
|
} else if (vision.monthDay && exifDate) {
|
||||||
|
date = `${exifDate.slice(0, 4)}-${vision.monthDay}`;
|
||||||
|
source = 'vision+exif_year';
|
||||||
|
reason = `${vision.reason ? vision.reason + ' ' : ''}(연도는 이미지에 없어 파일 메타데이터의 연도로 추정 — 반드시 확인하세요)`.trim();
|
||||||
|
} else if (exifDate) {
|
||||||
|
date = exifDate;
|
||||||
|
source = 'exif';
|
||||||
|
reason = vision.reason;
|
||||||
|
} else {
|
||||||
|
reason = vision.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default "what will the new name look like" shown to the user for review —
|
||||||
|
// a meaningful original name (e.g. "엄마 8-7.jpg") is kept as-is, a generic
|
||||||
|
// camera/screenshot name is replaced by the AI's content label. Either way
|
||||||
|
// it's just a prefill: the user can type over it before committing.
|
||||||
|
const originalStem = stripExistingDatePrefix(path.basename(relPath, path.extname(relPath)), relPath, caseAtStart);
|
||||||
|
const nameOverride = (isGenericFileName(originalStem) && contentLabel) ? contentLabel : originalStem;
|
||||||
|
|
||||||
|
const suggestion: DateSuggestion = {
|
||||||
|
id: 'sug_' + Math.random().toString(36).slice(2, 10),
|
||||||
|
relPath,
|
||||||
|
fileName: path.basename(relPath),
|
||||||
|
url: `/api/files/detective/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
|
||||||
|
kind: isText ? 'text' : 'photo',
|
||||||
|
date: date || null,
|
||||||
|
source,
|
||||||
|
reason: reason || null,
|
||||||
|
label: contentLabel || null,
|
||||||
|
nameOverride,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const casesPath = detectiveCasesPath(username);
|
||||||
|
const store = readCasesStore(casesPath);
|
||||||
|
const c = (store.cases || []).find((x: any) => x.id === caseId);
|
||||||
|
if (c) {
|
||||||
|
if (!Array.isArray(c.dateSuggestions)) c.dateSuggestions = [];
|
||||||
|
c.dateSuggestions = c.dateSuggestions.filter((s: any) => s.relPath !== relPath);
|
||||||
|
c.dateSuggestions.push(suggestion);
|
||||||
|
fs.mkdirSync(path.dirname(casesPath), { recursive: true });
|
||||||
|
const tmp = `${casesPath}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
||||||
|
fs.renameSync(tmp, casesPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renames the photo to "<date>_<content-or-original-name>.<ext>", copies the
|
||||||
|
// untouched original into a "원본/" subfolder next to it (so a wrong guess
|
||||||
|
// never loses the source file), and records the date on the case timeline —
|
||||||
|
// all as one action, since the file only gets touched once the user commits.
|
||||||
|
async function commitPhotoToTimeline(
|
||||||
|
username: string, caseId: string, relPath: string, date: string, label?: string,
|
||||||
|
): Promise<{ relPath: string; name: string; url: string; text: string; id: string }> {
|
||||||
|
if (!isValidCalendarDate(date)) throw new Error('invalid date');
|
||||||
|
const dir = detectiveFilesDir(username, caseId);
|
||||||
|
const segments = relPath.split('/').filter(Boolean);
|
||||||
|
const absPath = path.join(dir, ...segments);
|
||||||
|
if (!isPathInsideDir(dir, absPath) || !fs.existsSync(absPath)) throw new Error('file not found');
|
||||||
|
|
||||||
|
const casesPath = detectiveCasesPath(username);
|
||||||
|
const store = readCasesStore(casesPath);
|
||||||
|
const c = (store.cases || []).find((x: any) => x.id === caseId);
|
||||||
|
|
||||||
|
const fileDir = path.dirname(absPath);
|
||||||
|
const folderRel = segments.slice(0, -1).join('/');
|
||||||
|
const originalName = segments[segments.length - 1];
|
||||||
|
const ext = path.extname(originalName);
|
||||||
|
const originalStem = stripExistingDatePrefix(path.basename(originalName, ext), relPath, c);
|
||||||
|
|
||||||
|
// `label` here is whatever the user left in the editable name field (it
|
||||||
|
// starts prefilled with a sensible default, see nameOverride above, but the
|
||||||
|
// caller may have edited or cleared it) — an explicit non-empty value always
|
||||||
|
// wins; empty means "no override, keep the original file name". Either way
|
||||||
|
// stripExistingDatePrefix above keeps a re-commit from stacking dates.
|
||||||
|
const core = label && label.trim()
|
||||||
|
? sanitizeFileNameSegment(stripExistingDatePrefix(label.trim(), relPath, c))
|
||||||
|
: sanitizeFileNameSegment(originalStem);
|
||||||
|
let newName = `${date}_${core}${ext}`;
|
||||||
|
let newAbsPath = path.join(fileDir, newName);
|
||||||
|
let n = 2;
|
||||||
|
while (fs.existsSync(newAbsPath) && newAbsPath !== absPath) {
|
||||||
|
newName = `${date}_${core}_${n}${ext}`;
|
||||||
|
newAbsPath = path.join(fileDir, newName);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupDir = path.join(fileDir, '원본');
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
let backupPath = path.join(backupDir, originalName);
|
||||||
|
let bn = 2;
|
||||||
|
while (fs.existsSync(backupPath)) {
|
||||||
|
backupPath = path.join(backupDir, `${path.basename(originalName, ext)}_${bn}${ext}`);
|
||||||
|
bn++;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(absPath, backupPath);
|
||||||
|
fs.renameSync(absPath, newAbsPath);
|
||||||
|
|
||||||
|
const newRelPath = (folderRel ? `${folderRel}/` : '') + newName;
|
||||||
|
const newUrl = `/api/files/detective/${caseId}/${newRelPath.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
|
|
||||||
|
const icon = TEXT_DOC_EXTS.has(extOf(newName)) ? '📄' : '📷';
|
||||||
|
const text = `${icon} ${newName}`;
|
||||||
|
const id = 'tl_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
if (c) {
|
||||||
|
if (!Array.isArray(c.timeline)) c.timeline = [];
|
||||||
|
c.timeline.push({ id, date, text, relPath: newRelPath, url: newUrl });
|
||||||
|
if (Array.isArray(c.dateSuggestions)) {
|
||||||
|
c.dateSuggestions = c.dateSuggestions.filter((s: any) => s.relPath !== relPath);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(casesPath), { recursive: true });
|
||||||
|
const tmp = `${casesPath}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
||||||
|
fs.renameSync(tmp, casesPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { relPath: newRelPath, name: newName, url: newUrl, text, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDetectiveDateRoutes(app: express.Application, getSessionUser: (req: express.Request) => any): void {
|
||||||
|
app.post('/api/detective/files/date-suggest', async (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||||
|
const caseId = String(req.body?.caseId || '');
|
||||||
|
const relPath = String(req.body?.relPath || '');
|
||||||
|
const segments = relPath.split('/').filter(Boolean);
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some((s: string) => s === '..' || s === '.')) {
|
||||||
|
res.status(400).json({ error: 'invalid' }); return;
|
||||||
|
}
|
||||||
|
const ext = extOf(relPath);
|
||||||
|
if (!PHOTO_EXTS.has(ext) && !TEXT_DOC_EXTS.has(ext)) { res.status(400).json({ error: 'unsupported file type' }); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suggestion = await suggestPhotoDate(session.username, caseId, relPath);
|
||||||
|
res.json({ success: true, suggestion });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/detective/files/add-to-timeline', async (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||||
|
const caseId = String(req.body?.caseId || '');
|
||||||
|
const relPath = String(req.body?.relPath || '');
|
||||||
|
const date = String(req.body?.date || '');
|
||||||
|
const label = req.body?.label ? String(req.body.label) : undefined;
|
||||||
|
const segments = relPath.split('/').filter(Boolean);
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some((s: string) => s === '..' || s === '.')) {
|
||||||
|
res.status(400).json({ error: 'invalid' }); return;
|
||||||
|
}
|
||||||
|
if (!isValidCalendarDate(date)) { res.status(400).json({ error: 'invalid date' }); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await commitPhotoToTimeline(session.username, caseId, relPath, date, label);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+276
-1
@@ -23,6 +23,7 @@ import { registerMusicRoutes } from './routes-music';
|
|||||||
import { registerMCPRoutes } from './routes-mcp';
|
import { registerMCPRoutes } from './routes-mcp';
|
||||||
import { registerLanguageRoutes } from './routes-language';
|
import { registerLanguageRoutes } from './routes-language';
|
||||||
import { registerDentalRoutes } from './routes-dental';
|
import { registerDentalRoutes } from './routes-dental';
|
||||||
|
import { registerDetectiveDateRoutes } from './routes-detective-dates';
|
||||||
import {
|
import {
|
||||||
getConfig,
|
getConfig,
|
||||||
getAgents,
|
getAgents,
|
||||||
@@ -9334,6 +9335,204 @@ app.put('/api/detective/cases', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Per-case Detective File Attachments ─────────────────────────────────────
|
||||||
|
// Stored at <user-workspace>/detective/<caseId>/, served via /api/files/
|
||||||
|
|
||||||
|
function detectiveFilesDir(username: string, caseId: string): string {
|
||||||
|
return path.join(getUserWorkspace(username), 'detective', caseId);
|
||||||
|
}
|
||||||
|
function sanitizeDetectivePathSegment(seg: string): string {
|
||||||
|
const s = seg.replace(/[\x00-\x1f\x7f:*?"<>|]/g, '_').trim();
|
||||||
|
if (!s || s === '.' || s === '..') return '_';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Folder uploads send a relative path (e.g. "2024/IMG001.jpg") as the
|
||||||
|
// filename. Each path segment is sanitized and the real directory structure
|
||||||
|
// is recreated under the case folder, so a whole subfolder can later be
|
||||||
|
// deleted as a unit instead of only file-by-file.
|
||||||
|
function resolveDetectiveUploadPath(baseDir: string, originalName: string): { relPath: string; absPath: string } {
|
||||||
|
const rawSegments = String(originalName || 'upload').split(/[\\/]+/).filter(Boolean);
|
||||||
|
const segments = (rawSegments.length ? rawSegments : ['upload']).map(sanitizeDetectivePathSegment);
|
||||||
|
const folderSegs = segments.slice(0, -1);
|
||||||
|
let fileSeg = segments[segments.length - 1] || 'upload';
|
||||||
|
const dir = path.join(baseDir, ...folderSegs);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
if (fs.existsSync(path.join(dir, fileSeg))) {
|
||||||
|
const ext = path.extname(fileSeg);
|
||||||
|
const stem = path.basename(fileSeg, ext);
|
||||||
|
fileSeg = `${stem}_${Date.now()}${ext}`;
|
||||||
|
}
|
||||||
|
const relPath = [...folderSegs, fileSeg].join('/');
|
||||||
|
return { relPath, absPath: path.join(dir, fileSeg) };
|
||||||
|
}
|
||||||
|
function walkDetectiveFiles(dir: string, relBase = ''): Array<{ relPath: string; name: string; size: number; mtime: string }> {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
const out: Array<{ relPath: string; name: string; size: number; mtime: string }> = [];
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory() && entry.name === '원본') continue; // backups of renamed photos — kept on disk, hidden from the evidence list
|
||||||
|
const abs = path.join(dir, entry.name);
|
||||||
|
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
out.push(...walkDetectiveFiles(abs, rel));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const stat = fs.statSync(abs);
|
||||||
|
out.push({ relPath: rel, name: entry.name, size: stat.size, mtime: stat.mtime.toISOString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const DETECTIVE_FILE_CATEGORIES: Record<string, string> = {
|
||||||
|
jpg: 'photo', jpeg: 'photo', png: 'photo', gif: 'photo', webp: 'photo', bmp: 'photo', heic: 'photo', heif: 'photo', tiff: 'photo',
|
||||||
|
mp4: 'video', mov: 'video', avi: 'video', mkv: 'video', webm: 'video', m4v: 'video', wmv: 'video',
|
||||||
|
pdf: 'document', doc: 'document', docx: 'document', xls: 'document', xlsx: 'document', ppt: 'document', pptx: 'document', txt: 'document', hwp: 'document', hwpx: 'document',
|
||||||
|
};
|
||||||
|
function detectiveFileCategory(name: string): string {
|
||||||
|
const ext = path.extname(name).slice(1).toLowerCase();
|
||||||
|
return DETECTIVE_FILE_CATEGORIES[ext] || 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/detective/files', (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const caseId = String(req.query.caseId || '');
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) return res.status(400).json({ error: 'invalid caseId' });
|
||||||
|
const dir = detectiveFilesDir(session.username, caseId);
|
||||||
|
const files = walkDetectiveFiles(dir).map(e => {
|
||||||
|
const segs = e.relPath.split('/');
|
||||||
|
return {
|
||||||
|
name: e.name,
|
||||||
|
relPath: e.relPath,
|
||||||
|
folder: segs.length > 1 ? segs[0] : '',
|
||||||
|
size: e.size,
|
||||||
|
mtime: e.mtime,
|
||||||
|
url: `/api/files/detective/${caseId}/${segs.map(encodeURIComponent).join('/')}`,
|
||||||
|
category: detectiveFileCategory(e.name),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json({ files });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/detective/upload', (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
|
||||||
|
const caseId = String(req.query.caseId || '');
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) { res.status(400).json({ success: false, error: 'invalid caseId' }); return; }
|
||||||
|
const contentType = String(req.headers['content-type'] || '');
|
||||||
|
if (!contentType.includes('multipart/form-data')) {
|
||||||
|
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
|
||||||
|
}
|
||||||
|
const boundary = contentType.split('boundary=')[1];
|
||||||
|
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('binary');
|
||||||
|
const boundaryDelim = '--' + boundary;
|
||||||
|
|
||||||
|
let filename = 'upload.bin';
|
||||||
|
let filetype = 'application/octet-stream';
|
||||||
|
let fileData: Buffer | null = null;
|
||||||
|
|
||||||
|
const parts = raw.split(boundaryDelim);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part || part.trim() === '--' || part.trim() === '') continue;
|
||||||
|
const headerEnd = part.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd === -1) continue;
|
||||||
|
const header = part.substring(0, headerEnd);
|
||||||
|
if (!header.includes('name="file"')) continue;
|
||||||
|
|
||||||
|
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||||
|
?? header.match(/filename="([^"]+)"/);
|
||||||
|
if (fnMatch) {
|
||||||
|
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
|
||||||
|
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
|
||||||
|
: decodeURIComponent(fnMatch[1]);
|
||||||
|
filename = raw8;
|
||||||
|
}
|
||||||
|
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
|
if (ctMatch) filetype = ctMatch[1].trim();
|
||||||
|
|
||||||
|
const bodyStart = headerEnd + 4;
|
||||||
|
const bodyEnd = part.lastIndexOf('\r\n');
|
||||||
|
if (bodyEnd <= bodyStart) continue;
|
||||||
|
|
||||||
|
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
|
||||||
|
if (fileData.length > 50 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 50MB)' }); return; }
|
||||||
|
|
||||||
|
const dir = detectiveFilesDir(session.username, caseId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const { relPath, absPath } = resolveDetectiveUploadPath(dir, filename);
|
||||||
|
fs.writeFileSync(absPath, fileData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
name: path.basename(relPath),
|
||||||
|
relPath,
|
||||||
|
size: fileData.length,
|
||||||
|
type: filetype,
|
||||||
|
url: `/api/files/detective/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
|
||||||
|
category: detectiveFileCategory(relPath),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/detective/files', (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const caseId = String(req.body?.caseId || '');
|
||||||
|
// relPath may point at a single file or a whole subfolder (folder-level
|
||||||
|
// batch delete); name is kept as a fallback for the old root-file-only shape.
|
||||||
|
const relPath = String(req.body?.relPath || req.body?.name || '');
|
||||||
|
const segments = relPath.split('/').filter(Boolean);
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
|
||||||
|
return res.status(400).json({ error: 'invalid' });
|
||||||
|
}
|
||||||
|
const dir = detectiveFilesDir(session.username, caseId);
|
||||||
|
const targetPath = path.join(dir, ...segments);
|
||||||
|
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(targetPath);
|
||||||
|
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
|
||||||
|
else fs.unlinkSync(targetPath);
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overwrites a text evidence file (.txt/.md/.csv) with UTF-8 content from the
|
||||||
|
// built-in text editor -- lets legacy EUC-KR/CP949 documents be normalized to
|
||||||
|
// UTF-8 on save instead of staying garbled forever.
|
||||||
|
app.put('/api/detective/files/content', (req, res) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const caseId = String(req.body?.caseId || '');
|
||||||
|
const relPath = String(req.body?.relPath || '');
|
||||||
|
const content = req.body?.content;
|
||||||
|
const segments = relPath.split('/').filter(Boolean);
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.') || typeof content !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'invalid' });
|
||||||
|
}
|
||||||
|
const dir = detectiveFilesDir(session.username, caseId);
|
||||||
|
const targetPath = path.join(dir, ...segments);
|
||||||
|
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found' });
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(targetPath, content, 'utf-8');
|
||||||
|
const stat = fs.statSync(targetPath);
|
||||||
|
res.json({ success: true, size: stat.size, mtime: stat.mtime.toISOString() });
|
||||||
|
} catch (e: any) {
|
||||||
|
res.status(500).json({ error: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Per-user Investor Watchlist ─────────────────────────────────────────────
|
// ─── Per-user Investor Watchlist ─────────────────────────────────────────────
|
||||||
// Stored at <user-workspace>/.smallclaw/investor-watchlist.json
|
// Stored at <user-workspace>/.smallclaw/investor-watchlist.json
|
||||||
|
|
||||||
@@ -9543,6 +9742,77 @@ app.use('/wavacity', express.static(path.join(webUiPath, 'wavacity'), {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Entry point for opening the PDF editor scoped to a case: sets the
|
||||||
|
// dt_pdf_case cookie server-side, then redirects to a clean /pdf-editor/
|
||||||
|
// URL with no query string. (Stirling-PDF's root path has a bug where a
|
||||||
|
// query string on the bare context-path root causes a broken redirect —
|
||||||
|
// see /pdf-editor below — so the case id is never sent in that request.)
|
||||||
|
app.get('/pdf-editor-open', (req: express.Request, res: express.Response) => {
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
if (!session) { res.redirect('/login.html'); return; }
|
||||||
|
const caseId = String(req.query.caseId || '');
|
||||||
|
if (/^[a-zA-Z0-9_-]+$/.test(caseId)) {
|
||||||
|
res.setHeader('Set-Cookie', `dt_pdf_case=${caseId}; Path=/pdf-editor; HttpOnly; SameSite=Lax; Max-Age=3600`);
|
||||||
|
}
|
||||||
|
res.redirect('/pdf-editor/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stirling-PDF — self-hosted PDF editor (Docker, localhost:8090), reverse-proxied
|
||||||
|
// behind the gateway's own auth. Container runs with SERVER_SERVLET_CONTEXT_PATH=
|
||||||
|
// /pdf-editor so its self-referencing asset/API URLs match this mount point.
|
||||||
|
//
|
||||||
|
// Auto-save: the dt_pdf_case cookie (set by /pdf-editor-open above) ties this
|
||||||
|
// browser tab to a case. Any response that looks like a finished result (PDF
|
||||||
|
// content-type + a Content-Disposition filename, as opposed to preview/asset
|
||||||
|
// traffic) is teed into that case's folder in addition to being streamed to
|
||||||
|
// the browser as normal, so "download" in the editor also lands in the case.
|
||||||
|
app.use('/pdf-editor', (req: express.Request, res: express.Response) => {
|
||||||
|
const cookies = parseCookies(req);
|
||||||
|
const caseId = cookies['dt_pdf_case'] || '';
|
||||||
|
const session = getSessionUser(req);
|
||||||
|
|
||||||
|
const proxyReq = http.request({
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: 8090,
|
||||||
|
path: req.originalUrl,
|
||||||
|
method: req.method,
|
||||||
|
headers: { ...req.headers, host: '127.0.0.1:8090' },
|
||||||
|
}, (proxyRes) => {
|
||||||
|
const ct = String(proxyRes.headers['content-type'] || '');
|
||||||
|
const cd = String(proxyRes.headers['content-disposition'] || '');
|
||||||
|
const isResult = session && caseId && /^[a-zA-Z0-9_-]+$/.test(caseId)
|
||||||
|
&& ct.includes('application/pdf') && /filename=/i.test(cd);
|
||||||
|
if (!isResult) {
|
||||||
|
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
proxyRes.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
try {
|
||||||
|
const dir = detectiveFilesDir(session!.username, caseId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
let filename = 'edited.pdf';
|
||||||
|
const fnStar = cd.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
const fnPlain = cd.match(/filename="?([^";]+)"?/i);
|
||||||
|
if (fnStar) { try { filename = decodeURIComponent(fnStar[1]); } catch {} }
|
||||||
|
else if (fnPlain) filename = fnPlain[1];
|
||||||
|
const { relPath, absPath } = resolveDetectiveUploadPath(dir, filename);
|
||||||
|
fs.writeFileSync(absPath, body);
|
||||||
|
console.log(`[pdf-editor] saved result to case ${caseId}: ${relPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[pdf-editor] auto-save failed:', err);
|
||||||
|
}
|
||||||
|
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
proxyReq.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'PDF editor unavailable' }); });
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
});
|
||||||
|
|
||||||
app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } }));
|
app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } }));
|
||||||
|
|
||||||
// Serve code directory files for HTML preview (window.open)
|
// Serve code directory files for HTML preview (window.open)
|
||||||
@@ -9670,7 +9940,11 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
|
|||||||
try { reqPath = decodeURIComponent(reqPath); } catch {}
|
try { reqPath = decodeURIComponent(reqPath); } catch {}
|
||||||
console.log('[files] reqPath:', reqPath);
|
console.log('[files] reqPath:', reqPath);
|
||||||
if (!reqPath) { res.status(400).json({ error: 'No file path provided' }); return; }
|
if (!reqPath) { res.status(400).json({ error: 'No file path provided' }); return; }
|
||||||
if (reqPath.includes('..')) { res.status(403).json({ error: 'Access denied' }); return; }
|
// Segment-based check — a filename that merely *contains* ".." (e.g. a
|
||||||
|
// sentence ending in a period right before the extension, "....txt") is not
|
||||||
|
// a traversal attempt; only an actual ".." path segment is. The isAllowed
|
||||||
|
// check below (on the resolved absolute path) is the real security boundary.
|
||||||
|
if (reqPath.split('/').some(seg => seg === '..')) { res.status(403).json({ error: 'Access denied' }); return; }
|
||||||
const user = (req as any).user;
|
const user = (req as any).user;
|
||||||
const globalWorkspace = path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
|
const globalWorkspace = path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
|
||||||
const workspacePath = user?.workspace || globalWorkspace;
|
const workspacePath = user?.workspace || globalWorkspace;
|
||||||
@@ -13131,6 +13405,7 @@ app.get('/api/midi-download', async (req: any, res: any) => {
|
|||||||
|
|
||||||
registerLanguageRoutes(app);
|
registerLanguageRoutes(app);
|
||||||
registerDentalRoutes(app);
|
registerDentalRoutes(app);
|
||||||
|
registerDetectiveDateRoutes(app, getSessionUser);
|
||||||
|
|
||||||
// GET /api/excel/list — list xlsx files from user workspace
|
// GET /api/excel/list — list xlsx files from user workspace
|
||||||
app.get('/api/excel/list', (req, res) => {
|
app.get('/api/excel/list', (req, res) => {
|
||||||
|
|||||||
+768
-11
@@ -115,6 +115,18 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
|||||||
.dt-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
|
.dt-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
|
||||||
.dt-cl-item:hover .dt-cl-del{opacity:.5;}
|
.dt-cl-item:hover .dt-cl-del{opacity:.5;}
|
||||||
.dt-cl-del:hover{opacity:1 !important;color:#ef4444;}
|
.dt-cl-del:hover{opacity:1 !important;color:#ef4444;}
|
||||||
|
.dt-ev-group-title{font-size:11px;font-weight:700;color:var(--muted);margin:6px 0 2px;padding-left:2px;}
|
||||||
|
.dt-ev-photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;}
|
||||||
|
.dt-ev-photo{position:relative;border-radius:7px;overflow:hidden;border:1px solid var(--line);aspect-ratio:1;background:var(--panel);}
|
||||||
|
.dt-ev-photo img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||||
|
.dt-ev-photo .dt-ev-photo-del{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.55);border:none;color:#fff;border-radius:5px;font-size:10px;padding:1px 4px;cursor:pointer;opacity:0;transition:.12s;}
|
||||||
|
.dt-ev-photo:hover .dt-ev-photo-del{opacity:1;}
|
||||||
|
.dt-ev-folder-header{display:flex;align-items:center;justify-content:space-between;padding:6px 4px 4px;margin-top:10px;border-top:1px solid var(--line);font-size:12px;font-weight:700;color:var(--text);cursor:pointer;user-select:none;}
|
||||||
|
.dt-ev-folder-header .dt-cl-del{opacity:.6;font-size:10px;}
|
||||||
|
.dt-ev-folder-header .dt-cl-del:hover{opacity:1;}
|
||||||
|
.dt-ev-chevron{display:inline-block;width:11px;font-size:9px;color:var(--muted);}
|
||||||
|
.dt-ev-folder-body{padding-left:6px;}
|
||||||
|
.dt-ev-date-title{font-size:10px;font-weight:700;color:var(--muted);margin:6px 0 2px 2px;}
|
||||||
.dt-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
.dt-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
||||||
.dt-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
|
.dt-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
|
||||||
.dt-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
|
.dt-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
|
||||||
@@ -333,6 +345,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
|||||||
<button class="dt-tab-btn active" onclick="switchTab('notes')" id="tab-notes">📝 메모</button>
|
<button class="dt-tab-btn active" onclick="switchTab('notes')" id="tab-notes">📝 메모</button>
|
||||||
<button class="dt-tab-btn" onclick="switchTab('checklist')" id="tab-checklist">✅ 체크리스트</button>
|
<button class="dt-tab-btn" onclick="switchTab('checklist')" id="tab-checklist">✅ 체크리스트</button>
|
||||||
<button class="dt-tab-btn" onclick="switchTab('timeline')" id="tab-timeline">📅 타임라인</button>
|
<button class="dt-tab-btn" onclick="switchTab('timeline')" id="tab-timeline">📅 타임라인</button>
|
||||||
|
<button class="dt-tab-btn" onclick="switchTab('files')" id="tab-files">📎 증거자료</button>
|
||||||
|
<button class="dt-tab-btn" onclick="switchTab('docs')" id="tab-docs">📋 제출서류</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dt-tab-content">
|
<div class="dt-tab-content">
|
||||||
@@ -374,9 +388,41 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
|||||||
<input class="dt-tl-text-input" id="tl-input" type="text" placeholder="이벤트 내용 입력..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
|
<input class="dt-tl-text-input" id="tl-input" type="text" placeholder="이벤트 내용 입력..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
|
||||||
<button class="dt-tl-add-btn" onclick="addTimelineEvent()">추가</button>
|
<button class="dt-tl-add-btn" onclick="addTimelineEvent()">추가</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tl-suggestions"></div>
|
||||||
<div class="dt-tl-list" id="tl-list"></div>
|
<div class="dt-tl-list" id="tl-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Files pane -->
|
||||||
|
<div class="dt-tab-pane" id="pane-files">
|
||||||
|
<div class="dt-cl-wrap">
|
||||||
|
<div class="dt-cl-add-bar">
|
||||||
|
<input type="file" id="file-input" style="display:none" multiple onchange="onFilesPicked(this.files)">
|
||||||
|
<input type="file" id="folder-input" style="display:none" webkitdirectory directory multiple onchange="onFilesPicked(this.files)">
|
||||||
|
<input type="file" id="folder-add-file-input" style="display:none" multiple onchange="onFolderAddFilesPicked(this.files)">
|
||||||
|
<button class="dt-cl-add-btn" onclick="document.getElementById('file-input').click()">📎 증거자료 업로드</button>
|
||||||
|
<button class="dt-cl-add-btn" onclick="document.getElementById('folder-input').click()">📁 폴더 업로드</button>
|
||||||
|
<select id="ev-sort-mode" onchange="onEvidenceSortChange()" style="margin-left:auto;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:11px;color:var(--text);font-family:inherit;">
|
||||||
|
<option value="category">분류별</option>
|
||||||
|
<option value="date">날짜순</option>
|
||||||
|
</select>
|
||||||
|
<span id="file-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="dt-cl-list" id="files-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submission documents pane -->
|
||||||
|
<div class="dt-tab-pane" id="pane-docs">
|
||||||
|
<div class="dt-cl-wrap">
|
||||||
|
<div class="dt-cl-add-bar">
|
||||||
|
<input type="file" id="doc-input" style="display:none" multiple onchange="onDocsPicked(this.files)">
|
||||||
|
<button class="dt-cl-add-btn" onclick="document.getElementById('doc-input').click()">📋 서류 업로드</button>
|
||||||
|
<span id="doc-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="dt-cl-list" id="docs-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -573,10 +619,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
|||||||
<div class="dt-chat" id="chat-panel">
|
<div class="dt-chat" id="chat-panel">
|
||||||
<div class="dt-chat-hdr">
|
<div class="dt-chat-hdr">
|
||||||
<span class="dt-chat-label">🔍 탐정 AI</span>
|
<span class="dt-chat-label">🔍 탐정 AI</span>
|
||||||
|
<span id="dt-chat-ctx-label" style="font-size:10px;color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px" title="현재 대화 컨텍스트">일반</span>
|
||||||
<select class="dt-model-sel" id="model-sel" onchange="saveModel()">
|
<select class="dt-model-sel" id="model-sel" onchange="saveModel()">
|
||||||
<option value="">기본 모델</option>
|
<option value="">기본 모델</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="dt-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
<button class="dt-chat-clear" onclick="clearChat()" title="이 컨텍스트의 대화 지우기">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dt-msgs" id="chat-msgs"></div>
|
<div class="dt-msgs" id="chat-msgs"></div>
|
||||||
<div class="dt-input-bar">
|
<div class="dt-input-bar">
|
||||||
@@ -588,6 +635,33 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Built-in text editor for evidence .txt/.md/.csv files -- avoids opening the
|
||||||
|
raw file in a new tab, which garbles legacy EUC-KR/CP949 Korean documents
|
||||||
|
under the browser's UTF-8 assumption. -->
|
||||||
|
<div id="dt-text-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9999;align-items:center;justify-content:center;padding:18px">
|
||||||
|
<div style="width:min(720px,96vw);height:min(640px,90vh);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md,0 8px 30px rgba(0,0,0,.3));display:flex;flex-direction:column;overflow:hidden">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
|
<span style="font-weight:800;font-size:13px;flex-shrink:0">📝</span>
|
||||||
|
<span id="dt-te-filename" style="font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||||
|
<select id="dt-te-encoding" onchange="dtTextEditorRedecode()" style="margin-left:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit">
|
||||||
|
<option value="utf-8">UTF-8</option>
|
||||||
|
<option value="euc-kr">EUC-KR (CP949)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeTextEditor()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1;flex-shrink:0">✕</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="dt-te-textarea" style="flex:1;resize:none;border:0;outline:none;padding:14px 16px;font-family:'IBM Plex Mono',monospace;font-size:13px;line-height:1.6;background:var(--panel);color:var(--text)"></textarea>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0">
|
||||||
|
<span id="dt-te-status" style="font-size:11px;color:var(--muted)"></span>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button onclick="closeTextEditor()" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">닫기</button>
|
||||||
|
<button onclick="saveTextEditor()" style="border:0;background:var(--brand);color:#fff;border-radius:8px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer">저장 (UTF-8)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
const TOKEN_KEY='smallclaw_token';
|
const TOKEN_KEY='smallclaw_token';
|
||||||
@@ -638,12 +712,14 @@ function mobToggle(which){
|
|||||||
let activeTab='notes';
|
let activeTab='notes';
|
||||||
function switchTab(tab){
|
function switchTab(tab){
|
||||||
activeTab=tab;
|
activeTab=tab;
|
||||||
['notes','checklist','timeline'].forEach(t=>{
|
['notes','checklist','timeline','files','docs'].forEach(t=>{
|
||||||
document.getElementById('tab-'+t)?.classList.toggle('active',t===tab);
|
document.getElementById('tab-'+t)?.classList.toggle('active',t===tab);
|
||||||
document.getElementById('pane-'+t)?.classList.toggle('active',t===tab);
|
document.getElementById('pane-'+t)?.classList.toggle('active',t===tab);
|
||||||
});
|
});
|
||||||
if(tab==='checklist')renderChecklist();
|
if(tab==='checklist')renderChecklist();
|
||||||
if(tab==='timeline')renderTimeline();
|
if(tab==='timeline'){renderTimeline();renderDateSuggestions();}
|
||||||
|
if(tab==='files')loadFiles();
|
||||||
|
if(tab==='docs')loadSubmissionDocs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cases ─────────────────────────────────────────────────────────────────────
|
// ── Cases ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -656,6 +732,7 @@ function genId(){return 'case_'+Math.random().toString(36).slice(2,10);}
|
|||||||
function normalizeCase(c){
|
function normalizeCase(c){
|
||||||
if(!c.checklist)c.checklist=[];
|
if(!c.checklist)c.checklist=[];
|
||||||
if(!c.timeline)c.timeline=[];
|
if(!c.timeline)c.timeline=[];
|
||||||
|
if(!c.dateSuggestions)c.dateSuggestions=[];
|
||||||
if(!c.priority)c.priority='normal';
|
if(!c.priority)c.priority='normal';
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
@@ -668,9 +745,41 @@ async function loadCases(){
|
|||||||
renderCaseList();
|
renderCaseList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// timeline/dateSuggestions can be written server-side out-of-band, outside
|
||||||
|
// this full-array save entirely — the background date-recognition pipeline
|
||||||
|
// (routes-detective-dates.ts) writes those two fields directly to
|
||||||
|
// detective-cases.json, and another browser tab on the same case could too.
|
||||||
|
// A blind full-array PUT here would silently erase whatever a background
|
||||||
|
// call or another tab already persisted between this tab's last load and
|
||||||
|
// now. Before saving, merge in any timeline/dateSuggestions entries the
|
||||||
|
// server has that this tab doesn't know about yet (union by id — local
|
||||||
|
// entries always win on conflict, since a just-made local edit is the
|
||||||
|
// freshest truth). This can't perfectly reconcile deletions made in another
|
||||||
|
// tab, but silently resurrecting an already-deleted entry is a far smaller
|
||||||
|
// problem than silently losing one that was just added.
|
||||||
async function saveCases(){
|
async function saveCases(){
|
||||||
updateCurrentCaseFromDom();
|
updateCurrentCaseFromDom();
|
||||||
try{
|
try{
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/detective/cases',{headers:authH()});
|
||||||
|
if(r.ok){
|
||||||
|
const d=await r.json();
|
||||||
|
if(Array.isArray(d.cases)){
|
||||||
|
for(const freshCase of d.cases){
|
||||||
|
const localCase=cases.find(x=>x.id===freshCase.id);
|
||||||
|
if(!localCase)continue;
|
||||||
|
for(const field of['timeline','dateSuggestions']){
|
||||||
|
const localIds=new Set((localCase[field]||[]).map(x=>x.id));
|
||||||
|
const merged=[...(localCase[field]||[])];
|
||||||
|
for(const item of(freshCase[field]||[])){
|
||||||
|
if(!localIds.has(item.id))merged.push(item);
|
||||||
|
}
|
||||||
|
localCase[field]=merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch{}
|
||||||
await fetch('/api/detective/cases',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({cases})});
|
await fetch('/api/detective/cases',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({cases})});
|
||||||
showSaveIndicator('저장됨');
|
showSaveIndicator('저장됨');
|
||||||
}catch{showSaveIndicator('저장 실패');}
|
}catch{showSaveIndicator('저장 실패');}
|
||||||
@@ -756,6 +865,9 @@ function selectCase(id){
|
|||||||
renderCaseList();
|
renderCaseList();
|
||||||
if(activeTab==='checklist')renderChecklist();
|
if(activeTab==='checklist')renderChecklist();
|
||||||
if(activeTab==='timeline')renderTimeline();
|
if(activeTab==='timeline')renderTimeline();
|
||||||
|
if(activeTab==='files')loadFiles();
|
||||||
|
if(activeTab==='docs')loadSubmissionDocs();
|
||||||
|
ensureChatContextForCase();
|
||||||
if(window.innerWidth<=1024)mobCloseAll();
|
if(window.innerWidth<=1024)mobCloseAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,10 +882,12 @@ function newCase(){
|
|||||||
async function deleteCase(id){
|
async function deleteCase(id){
|
||||||
if(!confirm('이 사건을 삭제할까요?'))return;
|
if(!confirm('이 사건을 삭제할까요?'))return;
|
||||||
cases=cases.filter(c=>c.id!==id);
|
cases=cases.filter(c=>c.id!==id);
|
||||||
|
fetch('/api/chat/sessions/'+encodeURIComponent('dt_case_'+id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
||||||
if(currentCaseId===id){
|
if(currentCaseId===id){
|
||||||
currentCaseId=null;
|
currentCaseId=null;
|
||||||
document.getElementById('center-empty').style.display='';
|
document.getElementById('center-empty').style.display='';
|
||||||
document.getElementById('center-case').style.display='none';
|
document.getElementById('center-case').style.display='none';
|
||||||
|
ensureChatContextForCase();
|
||||||
}
|
}
|
||||||
await saveCases();
|
await saveCases();
|
||||||
renderCaseList();
|
renderCaseList();
|
||||||
@@ -783,6 +897,8 @@ function onCaseNameChange(){
|
|||||||
const c=cases.find(x=>x.id===currentCaseId);
|
const c=cases.find(x=>x.id===currentCaseId);
|
||||||
if(c)c.title=document.getElementById('case-name-input').value;
|
if(c)c.title=document.getElementById('case-name-input').value;
|
||||||
renderCaseList();
|
renderCaseList();
|
||||||
|
const label=document.getElementById('dt-chat-ctx-label');
|
||||||
|
if(label&&c)label.textContent=c.title||'제목 없음';
|
||||||
}
|
}
|
||||||
function onStatusChange(){
|
function onStatusChange(){
|
||||||
const c=cases.find(x=>x.id===currentCaseId);
|
const c=cases.find(x=>x.id===currentCaseId);
|
||||||
@@ -890,8 +1006,12 @@ function renderTimeline(){
|
|||||||
<div class="dt-tl-line"><div class="dt-tl-dot"></div><div class="dt-tl-rod"></div></div>
|
<div class="dt-tl-line"><div class="dt-tl-dot"></div><div class="dt-tl-rod"></div></div>
|
||||||
<div class="dt-tl-body">
|
<div class="dt-tl-body">
|
||||||
<div class="dt-tl-date-lbl">${escHtml(ev.date)}</div>
|
<div class="dt-tl-date-lbl">${escHtml(ev.date)}</div>
|
||||||
<div class="dt-tl-txt">
|
<div class="dt-tl-txt" style="display:flex;align-items:center;gap:8px">
|
||||||
${escHtml(ev.text)}
|
${ev.url?(DT_PHOTO_EXTS.test(ev.url)
|
||||||
|
?`<a href="${escAttr(ev.url)}" target="_blank" title="원본 크기로 보기" style="flex-shrink:0"><img src="${escAttr(ev.url)}" loading="lazy" style="width:36px;height:36px;object-fit:cover;border-radius:5px;border:1px solid var(--line)"></a>`
|
||||||
|
:`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(ev.relPath||'')}','${escJsAttr(ev.url)}','${escJsAttr((ev.relPath||'').split('/').pop())}')" title="문서 열기 (인코딩 자동 감지)" style="flex-shrink:0"><span style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:5px;border:1px solid var(--line);font-size:16px">📄</span></a>`
|
||||||
|
):''}
|
||||||
|
<span style="flex:1">${escHtml(ev.text)}</span>
|
||||||
<button class="dt-tl-del" onclick="deleteEvent('${ev.id}')">✕</button>
|
<button class="dt-tl-del" onclick="deleteEvent('${ev.id}')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,12 +1030,614 @@ function addTimelineEvent(){
|
|||||||
renderTimeline();saveCases();
|
renderTimeline();saveCases();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent(id){
|
async function deleteEvent(id){
|
||||||
const c=currentCase();if(!c)return;
|
const c=currentCase();if(!c)return;
|
||||||
|
const ev=(c.timeline||[]).find(x=>x.id===id);
|
||||||
|
// Entries linked to an evidence file (photo/document added via date
|
||||||
|
// recognition) get an extra choice: removing just the timeline entry
|
||||||
|
// leaves the file in the evidence tab untouched; confirming also deletes
|
||||||
|
// the working file (the untouched original still sits in 원본/ either way).
|
||||||
|
if(ev&&ev.relPath&&confirm(`타임라인 항목을 삭제합니다.\n연결된 파일도 함께 삭제할까요?\n"${ev.relPath}"\n\n확인 = 파일도 삭제 (원본은 원본/ 폴더에 남음)\n취소 = 타임라인 항목만 삭제, 파일은 유지`)){
|
||||||
|
try{
|
||||||
|
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath:ev.relPath})});
|
||||||
|
}catch{}
|
||||||
|
if(activeTab==='files')loadFiles();
|
||||||
|
}
|
||||||
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
|
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
|
||||||
renderTimeline();saveCases();
|
renderTimeline();saveCases();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Date recognition (photos: EXIF → vision LLM; text docs: content read) ────
|
||||||
|
// Runs automatically right after upload and can be re-run per file from the
|
||||||
|
// evidence tab. Results land as pending suggestions here — approving one
|
||||||
|
// copies it into the timeline, it never gets added silently.
|
||||||
|
const DT_PHOTO_EXTS=/\.(jpe?g|png|gif|webp|bmp|heic|heif|tiff)$/i;
|
||||||
|
const DT_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
|
||||||
|
const DT_TIMELINE_EXTS=/\.(jpe?g|png|gif|webp|bmp|heic|heif|tiff|txt|md|csv)$/i;
|
||||||
|
|
||||||
|
async function requestDateSuggestion(relPath){
|
||||||
|
if(!currentCaseId)return null;
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/detective/files/date-suggest',{
|
||||||
|
method:'POST',headers:authH({'Content-Type':'application/json'}),
|
||||||
|
body:JSON.stringify({caseId:currentCaseId,relPath}),
|
||||||
|
});
|
||||||
|
const d=await r.json();
|
||||||
|
if(!d.success||!d.suggestion)return null;
|
||||||
|
const c=currentCase();
|
||||||
|
if(c){
|
||||||
|
if(!c.dateSuggestions)c.dateSuggestions=[];
|
||||||
|
c.dateSuggestions=c.dateSuggestions.filter(s=>s.relPath!==relPath);
|
||||||
|
c.dateSuggestions.push(d.suggestion);
|
||||||
|
}
|
||||||
|
return d.suggestion;
|
||||||
|
}catch{return null;}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dtManualDateSuggest(relPath,btn){
|
||||||
|
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||||||
|
const s=await requestDateSuggestion(relPath);
|
||||||
|
if(btn){btn.disabled=false;btn.textContent='📅';}
|
||||||
|
renderDateSuggestions();
|
||||||
|
showSaveIndicator(s?(s.date?`날짜 인식: ${s.date}`:'날짜를 찾지 못했습니다'):'인식 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs `worker` over `items` with at most `concurrency` in flight at once —
|
||||||
|
// each date-recognition call is a slow (sometimes 60-90s) LLM round-trip, so
|
||||||
|
// fully sequential batches turned a 20-photo folder into a 20-30 minute wait.
|
||||||
|
// Different files never touch each other's state, so a small concurrency cap
|
||||||
|
// is safe and gives a large speedup without hammering the LLM endpoint.
|
||||||
|
async function runWithConcurrency(items,worker,concurrency){
|
||||||
|
let next=0;
|
||||||
|
async function run(){
|
||||||
|
while(next<items.length){
|
||||||
|
const i=next++;
|
||||||
|
await worker(items[i],i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({length:Math.min(concurrency,items.length)},run));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognizes every photo/text document in a folder at once, then hands off
|
||||||
|
// to the Timeline tab's suggestion panel where all of them can be reviewed
|
||||||
|
// and committed together with approveAllDateSuggestions() instead of one at
|
||||||
|
// a time.
|
||||||
|
async function dtFolderRecognize(folder,btn){
|
||||||
|
const items=dtLastEvidenceFiles.filter(f=>f.folder===folder&&(f.category==='photo'||DT_TEXT_EDIT_EXTS.test(f.name)));
|
||||||
|
if(!items.length){alert('이 폴더에 인식할 사진/문서가 없습니다.');return;}
|
||||||
|
if(!confirm(`"${folder}" 폴더의 사진/문서 ${items.length}개에서 날짜를 인식합니다.\n(개수에 따라 시간이 걸릴 수 있습니다)`))return;
|
||||||
|
const label=btn?btn.textContent:'';
|
||||||
|
if(btn)btn.disabled=true;
|
||||||
|
let progress=0;
|
||||||
|
await runWithConcurrency(items,async(item)=>{
|
||||||
|
await requestDateSuggestion(item.relPath);
|
||||||
|
progress++;
|
||||||
|
if(btn)btn.textContent=`인식 중... (${progress}/${items.length})`;
|
||||||
|
},3);
|
||||||
|
if(btn){btn.disabled=false;btn.textContent=label;}
|
||||||
|
switchTab('timeline');
|
||||||
|
showSaveIndicator(`${items.length}장 인식 완료 — 아래에서 확인 후 일괄 추가하세요`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DT_SOURCE_LABELS={exif:'EXIF',vision:'AI 인식','vision+exif_year':'AI 인식(연도 추정)',text:'AI 인식(문서)'};
|
||||||
|
|
||||||
|
function renderDateSuggestions(){
|
||||||
|
const box=document.getElementById('tl-suggestions');
|
||||||
|
if(!box)return;
|
||||||
|
const c=currentCase();
|
||||||
|
const list=c?(c.dateSuggestions||[]):[];
|
||||||
|
if(!list.length){box.innerHTML='';return;}
|
||||||
|
box.innerHTML=`<div style="margin:10px 0;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||||
|
<span style="font-size:11px;color:var(--muted);flex:1">📷📄 사진/문서에서 인식된 날짜 제안 (${list.length}) — 확인 후 추가하세요</span>
|
||||||
|
<button class="dt-tl-add-btn" style="padding:2px 8px" id="sug-approve-all-btn" onclick="approveAllDateSuggestions()">날짜 있는 항목 전체 추가</button>
|
||||||
|
</div>
|
||||||
|
${list.map(s=>`
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;font-size:12px;border-top:1px solid var(--line)">
|
||||||
|
${s.kind==='text'
|
||||||
|
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(s.relPath)}','${escJsAttr(s.url||'')}','${escJsAttr(s.fileName)}')" title="문서 열기 (인코딩 자동 감지)" style="flex-shrink:0"><span style="display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:5px;border:1px solid var(--line);font-size:18px">📄</span></a>`
|
||||||
|
:`<a href="${escAttr(s.url||'')}" target="_blank" title="원본 크기로 보기" style="flex-shrink:0"><img src="${escAttr(s.url||'')}" loading="lazy" style="width:44px;height:44px;object-fit:cover;border-radius:5px;border:1px solid var(--line)"></a>`}
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||||
|
<span style="font-size:10px;color:var(--muted);word-break:break-all">${escHtml(s.fileName)}</span>
|
||||||
|
<input type="text" id="sug-name-${s.id}" value="${escAttr(s.nameOverride||'')}" placeholder="새 파일 이름 (비우면 원본 이름 유지)" style="background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:3px 6px;font-size:12px;color:var(--text);font-family:inherit;width:100%">
|
||||||
|
</div>
|
||||||
|
<span style="color:var(--muted);font-size:10px;flex-shrink:0">${DT_SOURCE_LABELS[s.source]||s.source}</span>
|
||||||
|
<input type="date" id="sug-date-${s.id}" value="${s.date?escAttr(s.date):''}" class="dt-tl-date" style="width:130px;flex-shrink:0">
|
||||||
|
<button class="dt-tl-add-btn" style="padding:2px 8px" onclick="approveDateSuggestion('${s.id}')">타임라인에 추가</button>
|
||||||
|
<button class="dt-tl-del" style="position:static;opacity:.6" onclick="rejectDateSuggestion('${s.id}')" title="제안만 지우기 (파일은 유지)">✕</button>
|
||||||
|
<button class="dt-tl-del" style="position:static;opacity:.6" onclick="deleteFile('${escJsAttr(s.relPath)}')" title="파일 자체를 삭제">🗑</button>
|
||||||
|
</div>
|
||||||
|
${s.reason?`<div style="font-size:10px;color:var(--muted);margin:-2px 0 4px 52px">${escHtml(s.reason)}</div>`:''}
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Committing a suggestion actually touches the evidence file: renames it to
|
||||||
|
// "<date>_<content-or-original-name>.<ext>" and copies the untouched original
|
||||||
|
// into a 원본/ subfolder next to it, so a wrong AI guess never loses the
|
||||||
|
// source file. All done server-side in one call — see commitPhotoToTimeline.
|
||||||
|
async function commitDateSuggestion(caseId,relPath,date,label){
|
||||||
|
const r=await fetch('/api/detective/files/add-to-timeline',{
|
||||||
|
method:'POST',headers:authH({'Content-Type':'application/json'}),
|
||||||
|
body:JSON.stringify({caseId,relPath,date,label}),
|
||||||
|
});
|
||||||
|
const d=await r.json();
|
||||||
|
if(!d.success)throw new Error(d.error||'add-to-timeline failed');
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by approveDateSuggestion/approveAllDateSuggestions: commits one
|
||||||
|
// suggestion server-side, then mirrors the exact id/text/relPath/url the
|
||||||
|
// server persisted into the local timeline (never re-derive the icon/id
|
||||||
|
// client-side — the server already knows whether this was a photo or a
|
||||||
|
// text document, and re-guessing here is how the timeline icon used to
|
||||||
|
// drift out of sync with what's actually on disk).
|
||||||
|
async function commitOneSuggestion(c,s,date,name){
|
||||||
|
const result=await commitDateSuggestion(currentCaseId,s.relPath,date,name);
|
||||||
|
if(!c.timeline)c.timeline=[];
|
||||||
|
c.timeline.push({id:result.id,date,text:result.text,relPath:result.relPath,url:result.url});
|
||||||
|
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>x.id!==s.id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveDateSuggestion(id){
|
||||||
|
const c=currentCase();if(!c)return;
|
||||||
|
const s=(c.dateSuggestions||[]).find(x=>x.id===id);
|
||||||
|
if(!s)return;
|
||||||
|
const date=document.getElementById('sug-date-'+id)?.value;
|
||||||
|
if(!date){alert('날짜를 입력하세요.');return;}
|
||||||
|
const name=document.getElementById('sug-name-'+id)?.value||'';
|
||||||
|
try{
|
||||||
|
await commitOneSuggestion(c,s,date,name);
|
||||||
|
}catch(e){alert('타임라인 추가 실패: '+e.message);return;}
|
||||||
|
renderTimeline();renderDateSuggestions();loadFiles();
|
||||||
|
showSaveIndicator('타임라인에 추가됨');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectDateSuggestion(id){
|
||||||
|
const c=currentCase();if(!c)return;
|
||||||
|
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>x.id!==id);
|
||||||
|
renderDateSuggestions();saveCases();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commits every pending suggestion that currently has a date filled in (the
|
||||||
|
// per-row input, editable before this runs). Runs with a small concurrency
|
||||||
|
// cap rather than fully sequential or fully parallel — each commit is a slow
|
||||||
|
// server round-trip (rename + backup + JSON write) but nothing stops two
|
||||||
|
// different files' commits from happening at once, and fully serializing a
|
||||||
|
// folder of 10+ suggestions made this take minutes for no reason.
|
||||||
|
async function approveAllDateSuggestions(){
|
||||||
|
const c=currentCase();if(!c)return;
|
||||||
|
const list=[...(c.dateSuggestions||[])];
|
||||||
|
if(!list.length)return;
|
||||||
|
const btn=document.getElementById('sug-approve-all-btn');
|
||||||
|
if(btn)btn.disabled=true;
|
||||||
|
let done=0,failed=0,skipped=0;
|
||||||
|
const CONCURRENCY=3;
|
||||||
|
let next=0;
|
||||||
|
async function worker(){
|
||||||
|
while(next<list.length){
|
||||||
|
const s=list[next++];
|
||||||
|
const date=document.getElementById('sug-date-'+s.id)?.value;
|
||||||
|
if(!date){skipped++;continue;}
|
||||||
|
const name=document.getElementById('sug-name-'+s.id)?.value||'';
|
||||||
|
try{
|
||||||
|
await commitOneSuggestion(c,s,date,name);
|
||||||
|
done++;
|
||||||
|
}catch{failed++;}
|
||||||
|
if(btn)btn.textContent=`추가 중... (${done+failed+skipped}/${list.length})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({length:Math.min(CONCURRENCY,list.length)},worker));
|
||||||
|
renderTimeline();renderDateSuggestions();loadFiles();
|
||||||
|
showSaveIndicator(`${done}건 추가${skipped?`, ${skipped}건 날짜 없어 건너뜀`:''}${failed?`, ${failed}건 실패`:''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct "recognize" from the evidence tab itself — recognizes the date if
|
||||||
|
// it hasn't been already, then hands off to the Timeline tab's suggestion
|
||||||
|
// panel for review. It never commits by itself: renaming the file and
|
||||||
|
// backing up the original only happens after the user explicitly checks the
|
||||||
|
// date/name there and clicks "타임라인에 추가" — a native prompt() popup isn't
|
||||||
|
// a real review step, so this button doesn't skip straight to committing.
|
||||||
|
async function dtQuickAddToTimeline(relPath,btn){
|
||||||
|
const c=currentCase();if(!c)return;
|
||||||
|
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||||||
|
let s=(c.dateSuggestions||[]).find(x=>x.relPath===relPath);
|
||||||
|
if(!s)s=await requestDateSuggestion(relPath);
|
||||||
|
if(btn){btn.disabled=false;btn.textContent='🕐+';}
|
||||||
|
if(!s){alert('날짜 인식에 실패했습니다.');return;}
|
||||||
|
switchTab('timeline');
|
||||||
|
showSaveIndicator(s.date?`날짜 인식: ${s.date} — 아래에서 확인 후 추가하세요`:'날짜를 찾지 못했습니다 — 아래에서 직접 입력 후 추가하세요');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files ─────────────────────────────────────────────────────────────────────
|
||||||
|
function fmtFileSize(n){
|
||||||
|
if(n<1024)return n+'B';
|
||||||
|
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
|
||||||
|
return (n/1024/1024).toFixed(1)+'MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles(){
|
||||||
|
const list=document.getElementById('files-list');
|
||||||
|
if(!currentCaseId){list.innerHTML='';return;}
|
||||||
|
list.innerHTML='<div class="dt-cl-empty">불러오는 중...</div>';
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/detective/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||||||
|
const d=await r.json();
|
||||||
|
const all=Array.isArray(d.files)?d.files:[];
|
||||||
|
// Root-level documents live in the 제출서류 tab instead; documents
|
||||||
|
// inside a folder upload stay put so that folder's structure is intact.
|
||||||
|
renderFiles(all.filter(f=>f.folder||f.category!=='document'));
|
||||||
|
}catch{list.innerHTML='<div class="dt-cl-empty">파일 목록을 불러오지 못했습니다.</div>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubmissionDocs(){
|
||||||
|
const list=document.getElementById('docs-list');
|
||||||
|
if(!currentCaseId){list.innerHTML='';return;}
|
||||||
|
list.innerHTML='<div class="dt-cl-empty">불러오는 중...</div>';
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/detective/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||||||
|
const d=await r.json();
|
||||||
|
const all=Array.isArray(d.files)?d.files:[];
|
||||||
|
const docs=all.filter(f=>!f.folder&&f.category==='document').sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||||||
|
if(!docs.length){list.innerHTML='<div class="dt-cl-empty">등록된 제출서류가 없습니다.</div>';return;}
|
||||||
|
list.innerHTML=docs.map(f=>renderFileRow(f)).join('');
|
||||||
|
}catch{list.innerHTML='<div class="dt-cl-empty">서류 목록을 불러오지 못했습니다.</div>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDocsPicked(fileList){
|
||||||
|
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||||||
|
const status=document.getElementById('doc-upload-status');
|
||||||
|
const files=[...fileList];
|
||||||
|
for(let i=0;i<files.length;i++){
|
||||||
|
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||||||
|
try{await uploadOneFile(files[i]);}catch{}
|
||||||
|
}
|
||||||
|
status.textContent='';
|
||||||
|
document.getElementById('doc-input').value='';
|
||||||
|
loadSubmissionDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVIDENCE_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
|
||||||
|
const EVIDENCE_CATEGORY_ORDER=['photo','video','document','other'];
|
||||||
|
|
||||||
|
const EVIDENCE_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
|
||||||
|
let evidenceSortMode='category'; // 'category' | 'date'
|
||||||
|
|
||||||
|
function onEvidenceSortChange(){
|
||||||
|
evidenceSortMode=document.getElementById('ev-sort-mode').value;
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// name: display label. Root files show their bare name; files inside a
|
||||||
|
// folder-upload show the path relative to that folder, so the original
|
||||||
|
// structure stays visible instead of being flattened away.
|
||||||
|
function renderFileRow(f,name){
|
||||||
|
const isPdf=/\.pdf$/i.test(f.name);
|
||||||
|
const isText=DT_TEXT_EDIT_EXTS.test(f.name);
|
||||||
|
const icon=EVIDENCE_CATEGORY_ICONS[f.category]||'📦';
|
||||||
|
const nameHtml=isText
|
||||||
|
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(f.relPath)}','${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(name||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(name||f.name)}</a>`;
|
||||||
|
return `<div class="dt-cl-item">
|
||||||
|
<span style="flex-shrink:0">${icon}</span>
|
||||||
|
${nameHtml}
|
||||||
|
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
|
||||||
|
${isPdf?`<a href="/pdf-editor-open?caseId=${encodeURIComponent(currentCaseId)}" target="_blank" class="dt-cl-del" style="opacity:.7" title="PDF 편집기에서 열기 (수정 후 다운로드하면 사건 폴더에 자동 저장됩니다)">✏️</a>`:''}
|
||||||
|
${(f.category==='photo'||isText)?`<button class="dt-cl-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)">🕐+</button>
|
||||||
|
<button class="dt-cl-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="날짜 인식">📅</button>`:''}
|
||||||
|
<button class="dt-cl-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoryGroups(files){
|
||||||
|
const groups={};
|
||||||
|
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
|
||||||
|
return EVIDENCE_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
|
||||||
|
const items=groups[cat];
|
||||||
|
const title=`<div class="dt-ev-group-title">${EVIDENCE_CATEGORY_LABELS[cat]} (${items.length})</div>`;
|
||||||
|
if(cat==='photo'){
|
||||||
|
return title+`<div class="dt-ev-photo-grid">${items.map(f=>`
|
||||||
|
<div class="dt-ev-photo">
|
||||||
|
<a href="${escAttr(f.url)}" target="_blank" title="${escAttr(f.name)}"><img src="${escAttr(f.url)}" loading="lazy" alt="${escAttr(f.name)}"></a>
|
||||||
|
<button class="dt-ev-photo-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)" style="right:50px">🕐+</button>
|
||||||
|
<button class="dt-ev-photo-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="사진에서 날짜 인식" style="right:26px">📅</button>
|
||||||
|
<button class="dt-ev-photo-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||||||
|
</div>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
return title+items.map(f=>renderFileRow(f)).join('');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder uploads keep their original structure (no photo/video/document
|
||||||
|
// splitting) but are grouped by the date each file was added. Order within
|
||||||
|
// the folder (newest-first vs oldest-first) is per-folder, see dtGetFolderOrder.
|
||||||
|
function renderFolderContents(items,order){
|
||||||
|
const dateGroups={};
|
||||||
|
items.forEach(f=>{
|
||||||
|
const d=new Date(f.mtime);
|
||||||
|
const key=isNaN(d)?'날짜 없음':d.toISOString().slice(0,10);
|
||||||
|
(dateGroups[key]=dateGroups[key]||[]).push(f);
|
||||||
|
});
|
||||||
|
const dir=order==='asc'?1:-1;
|
||||||
|
return Object.keys(dateGroups).sort((a,b)=>dir*b.localeCompare(a)).map(dateKey=>{
|
||||||
|
const dItems=dateGroups[dateKey].sort((a,b)=>dir*(new Date(b.mtime)-new Date(a.mtime)));
|
||||||
|
return `<div class="dt-ev-date-title">📅 ${dateKey} (${dItems.length})</div>`
|
||||||
|
+dItems.map(f=>renderFileRow(f,f.relPath.slice(f.folder.length+1))).join('');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-folder item order (newest/oldest first) and folder-list order
|
||||||
|
// (name/date), both persisted in localStorage -- folder order per case,
|
||||||
|
// list order globally since it's a display preference, not case data.
|
||||||
|
function dtFolderOrderKey(folder){return 'dt_folder_order_'+(currentCaseId||'')+'_'+folder;}
|
||||||
|
function dtGetFolderOrder(folder){return localStorage.getItem(dtFolderOrderKey(folder))||'desc';}
|
||||||
|
function dtSetFolderOrder(folder,order){try{localStorage.setItem(dtFolderOrderKey(folder),order);}catch{}}
|
||||||
|
function dtToggleFolderOrder(folder){
|
||||||
|
dtSetFolderOrder(folder,dtGetFolderOrder(folder)==='desc'?'asc':'desc');
|
||||||
|
renderFiles(dtLastEvidenceFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
let folderSortMode=localStorage.getItem('dt_folder_sort_mode')||'name'; // 'name' | 'date'
|
||||||
|
function onFolderSortChange(){
|
||||||
|
folderSortMode=document.getElementById('folder-sort-mode').value;
|
||||||
|
try{localStorage.setItem('dt_folder_sort_mode',folderSortMode);}catch{}
|
||||||
|
renderFiles(dtLastEvidenceFiles);
|
||||||
|
}
|
||||||
|
function sortedFolderNames(folderMap){
|
||||||
|
const names=Object.keys(folderMap);
|
||||||
|
if(folderSortMode==='date'){
|
||||||
|
const latest=folder=>Math.max(...folderMap[folder].map(f=>new Date(f.mtime).getTime()||0));
|
||||||
|
return names.sort((a,b)=>latest(b)-latest(a));
|
||||||
|
}
|
||||||
|
return names.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remembers which folders are collapsed (per case, in localStorage) so
|
||||||
|
// re-rendering the list -- after an upload, a tab switch, or even a full
|
||||||
|
// page reload -- doesn't snap every folder back open.
|
||||||
|
function dtCollapsedKey(){return 'dt_collapsed_'+(currentCaseId||'');}
|
||||||
|
function dtLoadCollapsed(){
|
||||||
|
try{return new Set(JSON.parse(localStorage.getItem(dtCollapsedKey())||'[]'));}catch{return new Set();}
|
||||||
|
}
|
||||||
|
function dtSaveCollapsed(set){
|
||||||
|
try{localStorage.setItem(dtCollapsedKey(),JSON.stringify([...set]));}catch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtToggleFolder(headerEl){
|
||||||
|
const folder=headerEl.dataset.folder;
|
||||||
|
const body=headerEl.nextElementSibling;
|
||||||
|
const collapsed=body.style.display==='none';
|
||||||
|
body.style.display=collapsed?'':'none';
|
||||||
|
const chev=headerEl.querySelector('.dt-ev-chevron');
|
||||||
|
if(chev)chev.textContent=collapsed?'▾':'▸';
|
||||||
|
const set=dtLoadCollapsed();
|
||||||
|
if(collapsed)set.delete(folder);else set.add(folder);
|
||||||
|
dtSaveCollapsed(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dtLastEvidenceFiles=[];
|
||||||
|
|
||||||
|
function renderFiles(files){
|
||||||
|
dtLastEvidenceFiles=files;
|
||||||
|
const list=document.getElementById('files-list');
|
||||||
|
if(!files.length){list.innerHTML='<div class="dt-cl-empty">등록된 증거자료가 없습니다.</div>';return;}
|
||||||
|
|
||||||
|
if(evidenceSortMode==='date'){
|
||||||
|
const sorted=[...files].sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||||||
|
list.innerHTML=sorted.map(f=>renderFileRow(f,f.relPath)).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFiles=files.filter(f=>!f.folder);
|
||||||
|
const folderMap={};
|
||||||
|
files.filter(f=>f.folder).forEach(f=>{(folderMap[f.folder]=folderMap[f.folder]||[]).push(f);});
|
||||||
|
|
||||||
|
let html=rootFiles.length?renderCategoryGroups(rootFiles):'';
|
||||||
|
const collapsedSet=dtLoadCollapsed();
|
||||||
|
const folderSortBar=Object.keys(folderMap).length?`<div style="display:flex;justify-content:flex-end;margin-top:8px">
|
||||||
|
<select id="folder-sort-mode" onchange="onFolderSortChange()" style="background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:10px;color:var(--text);font-family:inherit;">
|
||||||
|
<option value="name"${folderSortMode==='name'?' selected':''}>폴더: 이름순</option>
|
||||||
|
<option value="date"${folderSortMode==='date'?' selected':''}>폴더: 최신순</option>
|
||||||
|
</select>
|
||||||
|
</div>`:'';
|
||||||
|
html+=folderSortBar;
|
||||||
|
sortedFolderNames(folderMap).forEach(folder=>{
|
||||||
|
const items=folderMap[folder];
|
||||||
|
const collapsed=collapsedSet.has(folder);
|
||||||
|
const order=dtGetFolderOrder(folder);
|
||||||
|
html+=`<div class="dt-ev-folder-header" data-folder="${escAttr(folder)}" onclick="dtToggleFolder(this)">
|
||||||
|
<span><span class="dt-ev-chevron">${collapsed?'▸':'▾'}</span> 📁 ${escHtml(folder)} (${items.length})</span>
|
||||||
|
<span>
|
||||||
|
<button class="dt-cl-del" onclick="event.stopPropagation();dtAddFilesToFolder('${escJsAttr(folder)}')" title="이 폴더에 파일 추가" style="opacity:.6;margin-right:8px">+ 파일 추가</button>
|
||||||
|
<button class="dt-cl-del" onclick="event.stopPropagation();dtFolderRecognize('${escJsAttr(folder)}',this)" title="폴더 내 사진 전체 날짜 인식 (타임라인 탭에서 확인 후 일괄 추가)" style="opacity:.6;margin-right:8px">🕐+ 폴더 인식</button>
|
||||||
|
<button class="dt-cl-del" onclick="event.stopPropagation();dtToggleFolderOrder('${escJsAttr(folder)}')" title="폴더 내 파일 정렬 순서 전환" style="opacity:.6;margin-right:8px">${order==='asc'?'🔼 오래된순':'🔽 최신순'}</button>
|
||||||
|
<button class="dt-cl-del" onclick="event.stopPropagation();deleteFolder('${escJsAttr(folder)}')" title="폴더 전체 삭제" style="opacity:.6">🗑 폴더 삭제</button>
|
||||||
|
</span>
|
||||||
|
</div><div class="dt-ev-folder-body"${collapsed?' style="display:none"':''}>`+renderFolderContents(items,order)+`</div>`;
|
||||||
|
});
|
||||||
|
list.innerHTML=html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFilesPicked(fileList){
|
||||||
|
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||||||
|
const status=document.getElementById('file-upload-status');
|
||||||
|
const files=[...fileList];
|
||||||
|
const recognizeRelPaths=[];
|
||||||
|
for(let i=0;i<files.length;i++){
|
||||||
|
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||||||
|
try{
|
||||||
|
const d=await uploadOneFile(files[i]);
|
||||||
|
if(DT_TIMELINE_EXTS.test(d.relPath))recognizeRelPaths.push(d.relPath);
|
||||||
|
}catch{}
|
||||||
|
}
|
||||||
|
document.getElementById('file-input').value='';
|
||||||
|
document.getElementById('folder-input').value='';
|
||||||
|
loadFiles();
|
||||||
|
let progress=0;
|
||||||
|
await runWithConcurrency(recognizeRelPaths,async(relPath)=>{
|
||||||
|
await requestDateSuggestion(relPath);
|
||||||
|
progress++;
|
||||||
|
status.textContent=`날짜 인식 중... (${progress}/${recognizeRelPaths.length})`;
|
||||||
|
},3);
|
||||||
|
status.textContent='';
|
||||||
|
renderDateSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds file(s) directly into an existing evidence folder, instead of the
|
||||||
|
// only previous options (root upload, or a whole new folder via directory
|
||||||
|
// picker). Reuses uploadOneFile with an explicit targetName so the server
|
||||||
|
// places it under <folder>/<name> rather than at the root.
|
||||||
|
let dtFolderAddTarget=null;
|
||||||
|
function dtAddFilesToFolder(folder){
|
||||||
|
dtFolderAddTarget=folder;
|
||||||
|
document.getElementById('folder-add-file-input').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFolderAddFilesPicked(fileList){
|
||||||
|
if(!currentCaseId||!dtFolderAddTarget)return;
|
||||||
|
const folder=dtFolderAddTarget;
|
||||||
|
const status=document.getElementById('file-upload-status');
|
||||||
|
const files=[...fileList];
|
||||||
|
const recognizeRelPaths=[];
|
||||||
|
for(let i=0;i<files.length;i++){
|
||||||
|
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||||||
|
try{
|
||||||
|
const targetName=folder+'/'+files[i].name;
|
||||||
|
const d=await uploadOneFile(files[i],targetName);
|
||||||
|
if(DT_TIMELINE_EXTS.test(d.relPath))recognizeRelPaths.push(d.relPath);
|
||||||
|
}catch{}
|
||||||
|
}
|
||||||
|
document.getElementById('folder-add-file-input').value='';
|
||||||
|
dtFolderAddTarget=null;
|
||||||
|
loadFiles();
|
||||||
|
let progress=0;
|
||||||
|
await runWithConcurrency(recognizeRelPaths,async(relPath)=>{
|
||||||
|
await requestDateSuggestion(relPath);
|
||||||
|
progress++;
|
||||||
|
status.textContent=`날짜 인식 중... (${progress}/${recognizeRelPaths.length})`;
|
||||||
|
},3);
|
||||||
|
status.textContent='';
|
||||||
|
renderDateSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadOneFile(file,targetName){
|
||||||
|
return new Promise((resolve,reject)=>{
|
||||||
|
const fd=new FormData();
|
||||||
|
// webkitRelativePath (folder uploads) keeps the subfolder as part of the
|
||||||
|
// filename so files from different subfolders don't silently collide.
|
||||||
|
// targetName overrides both when adding a file into a specific existing
|
||||||
|
// folder (see dtAddFilesToFolder).
|
||||||
|
const name=targetName||file.webkitRelativePath||file.name;
|
||||||
|
fd.append('file',file,name);
|
||||||
|
fetch('/api/detective/upload?caseId='+encodeURIComponent(currentCaseId),{
|
||||||
|
method:'POST',headers:authH(),body:fd,
|
||||||
|
}).then(r=>r.json()).then(d=>d.success?resolve(d):reject(d)).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in text editor (.txt/.md/.csv) ─────────────────────────────────────
|
||||||
|
// Old Korean documents are often EUC-KR/CP949, not UTF-8. Opening them via a
|
||||||
|
// plain <a target=_blank> lets the browser guess UTF-8 and garble the text,
|
||||||
|
// so this fetches the raw bytes and decodes them client-side instead, with a
|
||||||
|
// manual encoding override in case auto-detect picks the wrong one.
|
||||||
|
let dtTeState=null; // {caseId, relPath, name, bytes}
|
||||||
|
|
||||||
|
function decodeTextBytes(buf,encoding){
|
||||||
|
if(encoding){
|
||||||
|
try{return new TextDecoder(encoding).decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||||||
|
}
|
||||||
|
try{return new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{}
|
||||||
|
try{return new TextDecoder('euc-kr').decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTextEditor(relPath,url,name){
|
||||||
|
const modal=document.getElementById('dt-text-editor-modal');
|
||||||
|
const ta=document.getElementById('dt-te-textarea');
|
||||||
|
const status=document.getElementById('dt-te-status');
|
||||||
|
document.getElementById('dt-te-filename').textContent=name;
|
||||||
|
ta.value='불러오는 중...';
|
||||||
|
status.textContent='';
|
||||||
|
modal.style.display='flex';
|
||||||
|
try{
|
||||||
|
const r=await fetch(url,{headers:authH(),cache:'no-store'});
|
||||||
|
const buf=await r.arrayBuffer();
|
||||||
|
const isUtf8=(()=>{try{new TextDecoder('utf-8',{fatal:true}).decode(buf);return true;}catch{return false;}})();
|
||||||
|
dtTeState={caseId:currentCaseId,relPath,name,bytes:buf};
|
||||||
|
document.getElementById('dt-te-encoding').value=isUtf8?'utf-8':'euc-kr';
|
||||||
|
ta.value=decodeTextBytes(buf,isUtf8?'utf-8':'euc-kr');
|
||||||
|
status.textContent=isUtf8?'':'⚠️ UTF-8이 아닌 것으로 보여 EUC-KR로 자동 표시했습니다. 깨져 보이면 인코딩을 바꿔보세요.';
|
||||||
|
}catch(e){
|
||||||
|
ta.value='';
|
||||||
|
status.textContent='파일을 불러오지 못했습니다: '+e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtTextEditorRedecode(){
|
||||||
|
if(!dtTeState)return;
|
||||||
|
const enc=document.getElementById('dt-te-encoding').value;
|
||||||
|
document.getElementById('dt-te-textarea').value=decodeTextBytes(dtTeState.bytes,enc);
|
||||||
|
document.getElementById('dt-te-status').textContent='';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTextEditor(){
|
||||||
|
document.getElementById('dt-text-editor-modal').style.display='none';
|
||||||
|
dtTeState=null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTextEditor(){
|
||||||
|
if(!dtTeState)return;
|
||||||
|
const status=document.getElementById('dt-te-status');
|
||||||
|
status.textContent='저장 중...';
|
||||||
|
try{
|
||||||
|
const content=document.getElementById('dt-te-textarea').value;
|
||||||
|
const r=await fetch('/api/detective/files/content',{
|
||||||
|
method:'PUT',headers:authH({'Content-Type':'application/json'}),
|
||||||
|
body:JSON.stringify({caseId:dtTeState.caseId,relPath:dtTeState.relPath,content}),
|
||||||
|
});
|
||||||
|
const d=await r.json();
|
||||||
|
if(!r.ok||!d.success)throw new Error(d.error||'save failed');
|
||||||
|
status.textContent='저장됨 (UTF-8)';
|
||||||
|
setTimeout(closeTextEditor,600);
|
||||||
|
}catch(e){
|
||||||
|
status.textContent='저장 실패: '+e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps the timeline/suggestion panel in sync when a file is removed from the
|
||||||
|
// evidence tab directly, so a delete there doesn't leave a dangling entry
|
||||||
|
// with a broken thumbnail pointing at a file that no longer exists.
|
||||||
|
function dtPruneTimelineForPath(matches){
|
||||||
|
const c=currentCase();if(!c)return;
|
||||||
|
const before=(c.timeline||[]).length+(c.dateSuggestions||[]).length;
|
||||||
|
c.timeline=(c.timeline||[]).filter(x=>!x.relPath||!matches(x.relPath));
|
||||||
|
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>!matches(x.relPath));
|
||||||
|
if((c.timeline.length+c.dateSuggestions.length)!==before){renderTimeline();renderDateSuggestions();saveCases();}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(relPath){
|
||||||
|
if(!currentCaseId)return;
|
||||||
|
if(!confirm('이 파일을 삭제할까요?'))return;
|
||||||
|
try{
|
||||||
|
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath})});
|
||||||
|
}catch{}
|
||||||
|
dtPruneTimelineForPath(p=>p===relPath);
|
||||||
|
if(activeTab==='docs')loadSubmissionDocs();else loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder(folder){
|
||||||
|
if(!currentCaseId)return;
|
||||||
|
if(!confirm(`'${folder}' 폴더의 파일을 전부 삭제할까요? 되돌릴 수 없습니다.`))return;
|
||||||
|
try{
|
||||||
|
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath:folder})});
|
||||||
|
}catch{}
|
||||||
|
dtPruneTimelineForPath(p=>p===folder||p.startsWith(folder+'/'));
|
||||||
|
const set=dtLoadCollapsed();
|
||||||
|
set.delete(folder);
|
||||||
|
dtSaveCollapsed(set);
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
// ── AI Actions ────────────────────────────────────────────────────────────────
|
// ── AI Actions ────────────────────────────────────────────────────────────────
|
||||||
function askAiAboutCase(){
|
function askAiAboutCase(){
|
||||||
const c=currentCase();
|
const c=currentCase();
|
||||||
@@ -1079,7 +1801,13 @@ function saveModel(){selectedModel=document.getElementById('model-sel').value;tr
|
|||||||
const APP_KEY='dt';
|
const APP_KEY='dt';
|
||||||
let skillContext='';
|
let skillContext='';
|
||||||
let _sessionId=null;
|
let _sessionId=null;
|
||||||
function getSessionId(){return _sessionId||'dt_main';}
|
// Each case gets its own AI conversation (derived straight from the case id,
|
||||||
|
// no extra field to persist) so investigating one case never leaks context
|
||||||
|
// into another. With no case selected, chat falls back to a general session.
|
||||||
|
function getSessionId(){
|
||||||
|
if(currentCaseId)return 'dt_case_'+currentCaseId;
|
||||||
|
return _sessionId||'dt_main';
|
||||||
|
}
|
||||||
async function initSessionId(){
|
async function initSessionId(){
|
||||||
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
|
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
|
||||||
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
||||||
@@ -1088,6 +1816,7 @@ async function initSessionId(){
|
|||||||
|
|
||||||
async function loadHistory(){
|
async function loadHistory(){
|
||||||
const sid=getSessionId();
|
const sid=getSessionId();
|
||||||
|
document.getElementById('chat-msgs').innerHTML='';
|
||||||
if(!sid||sid.endsWith('_main'))return;
|
if(!sid||sid.endsWith('_main'))return;
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
||||||
@@ -1107,6 +1836,20 @@ async function loadHistory(){
|
|||||||
}catch{}
|
}catch{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switches the chat panel to whichever case is currently selected (or the
|
||||||
|
// general session when none is) and reloads that context's own history --
|
||||||
|
// this is what keeps each case's AI conversation separate from the others.
|
||||||
|
let _chatCtxCaseId='__unset__';
|
||||||
|
async function ensureChatContextForCase(){
|
||||||
|
if(_chatCtxCaseId===currentCaseId)return;
|
||||||
|
_chatCtxCaseId=currentCaseId;
|
||||||
|
const c=cases.find(x=>x.id===currentCaseId);
|
||||||
|
const label=document.getElementById('dt-chat-ctx-label');
|
||||||
|
if(label)label.textContent=c?c.title||'제목 없음':'일반';
|
||||||
|
await loadHistory();
|
||||||
|
addMsg('system',c?`📁 '${escHtml(c.title||'제목 없음')}' 사건 컨텍스트입니다. 이 대화는 이 사건에서만 이어집니다.`:'🔍 일반 조사 컨텍스트입니다. (선택된 사건 없음)');
|
||||||
|
}
|
||||||
|
|
||||||
const SKILL_CONTENT=`---
|
const SKILL_CONTENT=`---
|
||||||
name: Detective
|
name: Detective
|
||||||
description: 공개 정보 기반 인물·기업·사건 조사 전문 어시스턴트. 사실 우선, 가설 수립, 증거 수집, 구조적 보고.
|
description: 공개 정보 기반 인물·기업·사건 조사 전문 어시스턴트. 사실 우선, 가설 수립, 증거 수집, 구조적 보고.
|
||||||
@@ -1225,6 +1968,16 @@ function loadSkill(){
|
|||||||
|
|
||||||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||||||
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||||
|
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||||||
|
// For values interpolated into a single-quoted JS string literal INSIDE an
|
||||||
|
// onclick="..." attribute (e.g. onclick="deleteFile('${escJsAttr(relPath)}')").
|
||||||
|
// escAttr alone is NOT enough here: the browser HTML-decodes the attribute
|
||||||
|
// value before treating it as JS, so an escAttr'd quote (') still becomes
|
||||||
|
// a real ' by the time it reaches the JS parser and can break out of the
|
||||||
|
// string literal (e.g. a filename like x'),alert(1),('y). This escapes the
|
||||||
|
// backslash/quote for the JS-string layer first, then HTML-escapes the
|
||||||
|
// result for the attribute layer, so both decoding steps land safely.
|
||||||
|
function escJsAttr(s){return escAttr(String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,''));}
|
||||||
|
|
||||||
function addMsg(role,html){
|
function addMsg(role,html){
|
||||||
const msgs=document.getElementById('chat-msgs');
|
const msgs=document.getElementById('chat-msgs');
|
||||||
@@ -1864,10 +2617,14 @@ async function callChatRaw(message){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearChat(){
|
async function clearChat(){
|
||||||
if(!confirm('대화를 지울까요?'))return;
|
if(!confirm('이 컨텍스트의 대화를 지울까요?'))return;
|
||||||
|
const sid=getSessionId();
|
||||||
document.getElementById('chat-msgs').innerHTML='';
|
document.getElementById('chat-msgs').innerHTML='';
|
||||||
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||||||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
if(!currentCaseId){
|
||||||
|
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
||||||
|
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||||||
|
}
|
||||||
addMsg('system','새 대화가 시작되었습니다. 조사 요청을 입력하세요.');
|
addMsg('system','새 대화가 시작되었습니다. 조사 요청을 입력하세요.');
|
||||||
}
|
}
|
||||||
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
||||||
@@ -1879,9 +2636,9 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
|
|||||||
// Set today as default timeline date
|
// Set today as default timeline date
|
||||||
document.getElementById('tl-date').value=new Date().toISOString().slice(0,10);
|
document.getElementById('tl-date').value=new Date().toISOString().slice(0,10);
|
||||||
await initSessionId();
|
await initSessionId();
|
||||||
await loadHistory();
|
|
||||||
loadSkill();
|
loadSkill();
|
||||||
await Promise.all([loadCases(),loadModels()]);
|
await Promise.all([loadCases(),loadModels()]);
|
||||||
|
await ensureChatContextForCase();
|
||||||
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
|
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
|
||||||
initAppVoice(sendMessage);
|
initAppVoice(sendMessage);
|
||||||
document.getElementById('chat-input').focus();
|
document.getElementById('chat-input').focus();
|
||||||
|
|||||||
Reference in New Issue
Block a user