Files
inkling/src/renderer/inbox/components/NoteCard.tsx
th-kim0823 d3bc972783 fix(vision): 본문 빈 + 이미지 only 케이스 AI 호출 skip
gemma4:26b 등 vision 모델이 본문 없는 이미지 단독 입력을 의미 있게 처리 못 함
(여러 prompt 시도에도 빈 응답). 모델 한계 수용:

- AiWorker 가 rawText.trim()==='' && media.length>0 detect 시 vision call skip
- 자동 placeholder: '첨부 이미지' / '첨부 이미지 N장' + summary
- ai_provider='image-only-skip' (디버그성 식별자)
- NoteCard 노란 배너 제거 (사용자가 한계 수용, placeholder 자체로 충분)
- 사용자는 EditableField 로 제목/요약 직접 편집 가능

cold-start timeout / parseJsonLoose fallback / schema coerce 부담 모두 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:58 +09:00

548 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import type { Note } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal } from './MoveStatusModal.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
note: Note;
onDeleted?: () => void; // inbox mode 전용 (trash mode 에서 미사용)
onUpdated: (n: Note) => void;
mode?: 'inbox' | 'trash'; // default 'inbox'
onRestore?: () => void;
onPermanentDelete?: () => void;
}
const aiBadgeStyle: React.CSSProperties = {
display: 'inline-block', marginLeft: 6, padding: '1px 5px',
background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle'
};
function isPastDue(iso: string, today: string): boolean {
return iso < today; // string comparison works for ISO YYYY-MM-DD
}
function todayKstIso(): string {
const k = new Date(Date.now() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}
function DueDateBadge({
value,
isEdited,
today,
onSave
}: {
value: string | null;
isEdited: boolean;
today: string;
onSave: (next: string) => Promise<void>;
}): React.ReactElement {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
React.useEffect(() => { if (!editing) setDraft(value ?? ''); }, [value, editing]);
if (!editing) {
if (value === null) {
return (
<span
onClick={() => setEditing(true)}
style={{ fontSize: 11, color: '#bbb', cursor: 'pointer' }}
title="마감일 추가"
>
📅
</span>
);
}
const past = isPastDue(value, today);
return (
<span style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
onClick={() => setEditing(true)}
style={{
color: past ? '#999' : '#666',
textDecoration: past ? 'line-through' : 'none',
cursor: 'pointer'
}}
title={past ? '지난 마감일 — 클릭으로 편집' : '클릭으로 편집'}
>
📅 {value}
</span>
{!isEdited && (
<span
style={{
fontSize: 9, padding: '0 4px', background: '#eee',
color: '#888', borderRadius: 2
}}
title="AI 추출"
>
AI
</span>
)}
</span>
);
}
return (
<input
type="date"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={async () => {
try { await onSave(draft); }
catch { /* keep editing if invalid */ return; }
setEditing(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') { setDraft(value ?? ''); setEditing(false); }
}}
autoFocus
style={{ fontSize: 11, padding: 1 }}
/>
);
}
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
const isTrash = mode === 'trash';
// v0.2.9 Cut B Task 13 — ai_status='disabled' 노트는 raw_text 가 1차 정보. 원문 펼침 default 켬.
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// 이동 modal 열림 여부. 클릭 시 MoveStatusModal 에서 사유 + AI 분류 + 수동 분류 선택.
const [moveOpen, setMoveOpen] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
React.useEffect(() => { setLocal(note); }, [note]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function saveTitle(next: string) {
await inboxApi.updateAiFields(note.id, { title: next });
const updated = { ...local, aiTitle: next, titleEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function saveSummary(next: string) {
await inboxApi.updateAiFields(note.id, { summary: next });
const updated = { ...local, aiSummary: next, summaryEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function saveDueDate(next: string) {
const value = next.trim() === '' ? null : next.trim();
// Light validation: empty or YYYY-MM-DD
if (value !== null && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('Invalid date');
}
await inboxApi.setDueDate(note.id, value);
const updated = { ...local, dueDate: value, dueDateEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function saveRaw() {
const next = draftRaw;
if (next.trim().length === 0) return;
const r = await inboxApi.updateRawText(note.id, next);
if (!r.ok) return;
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
setEditingRaw(false);
}
async function removeTag(tagName: string) {
const removed = local.tags.find((t) => t.name === tagName);
const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
await inboxApi.updateAiFields(note.id, { tags: nextTagNames });
const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) };
setLocal(updated); onUpdated(updated);
if (removed !== undefined) {
pushTagUndo({
noteId: note.id,
tag: removed,
onUndo: async () => {
// Restore by re-adding the removed tag.
// Note: source flag is reset to 'user' on the backend by `updateAiFields` (it
// only takes names), which mirrors current behaviour for any user-driven
// tag mutation. Visual restoration matches latest local state.
const restoredNames = [...updated.tags.map((t) => t.name), removed.name];
await inboxApi.updateAiFields(note.id, { tags: restoredNames });
const u2 = { ...updated, tags: [...updated.tags, removed] };
setLocal(u2); onUpdated(u2);
}
});
}
}
function filterByTag(tagName: string): void {
useInbox.getState().setTagFilter(tagName);
}
async function saveIntent(next: string) {
await inboxApi.setIntent(note.id, next);
const now = new Date().toISOString();
const updated = { ...local, userIntent: next, intentPromptedAt: local.intentPromptedAt ?? now };
setLocal(updated); onUpdated(updated);
}
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
return (
// id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
{!isTrash && showIntentBanner && (
<IntentBanner
noteId={note.id}
onResolved={(intentText) => {
const now = new Date().toISOString();
const updated = { ...local, userIntent: intentText ?? null, intentPromptedAt: now };
setLocal(updated); onUpdated(updated);
}}
/>
)}
{local.aiStatus === 'pending' && (
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#666' }}>
Inkling이
</div>
{/* v0.3.9 — pending cancel UI. Ollama 끊김 / 무한 pending 시 사용자 unblock path. */}
<button
onClick={async () => {
await inboxApi.cancelPending(local.id);
// push 기반 — onNoteUpdated 가 store 자동 갱신.
}}
style={{
background: 'none', border: '1px solid #ccc', color: '#666',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
}}
title="AI 자동 처리를 건너뛰고 원문만 보관"
>
</button>
</div>
)}
{local.aiStatus === 'failed' && (
<div style={{ marginTop: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
</div>
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
<button
onClick={async () => {
await inboxApi.retryOneFailed(local.id);
}}
style={{
background: 'none', border: '1px solid #a55', color: '#a55',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
}}
title="이 노트만 AI 처리 재시도"
>
</button>
</div>
{/* v0.3.14 — fail 원인 inline 표시. ai_error 의 raw message 가 그대로 사용자에게
보여서 디버깅 + 모델/네트워크 이슈 진단 가능. 너무 길면 <details> 로 접힘. */}
{local.aiError !== null && local.aiError.length > 0 && (
<details style={{ marginTop: 4 }}>
<summary style={{ fontSize: 12, color: '#a55', cursor: 'pointer' }}>
</summary>
<pre style={{
fontSize: 11, color: '#666', background: '#fff0f0', padding: 6,
borderRadius: 4, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word'
}}>
{local.aiError}
</pre>
</details>
)}
</div>
)}
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
summary/tags 는 hide. 원문은 아래 "원문 보기" 영역에서 항상 표시. */}
{isAiDisabled && (
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{fallbackTitle}</h3>
</div>
)}
{local.aiStatus === 'done' && (
<>
{isTrash ? (
<>
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{local.aiTitle ?? '(제목 없음)'}</h3>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}>
{local.aiSummary ?? '(요약 없음)'}
</div>
{local.dueDate !== null && (
<div style={{ marginTop: 6 }}>
<span style={{ fontSize: 11, color: '#666' }}>📅 {local.dueDate}</span>
</div>
)}
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 8px',
borderRadius: 12,
fontSize: 12
}}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginTop: 4 }}>
<EditableField
value={local.aiTitle ?? ''}
onSave={saveTitle}
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
singleLine
/>
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<EditableField
value={local.aiSummary ?? ''}
onSave={saveSummary}
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
singleLine={false}
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</span>
))}
</div>
)}
{local.userIntent !== null && (
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
<EditableField
value={local.userIntent}
onSave={saveIntent}
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
singleLine
/>
</div>
)}
</>
)}
</>
)}
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
// alt="" — decorative (relPath 는 사용자 의미 X). title 이 hover tooltip.
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => { void inboxApi.openMedia(m.relPath); }}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
</div>
)}
<div style={{ marginTop: 10 }}>
<button onClick={() => setRawOpen((o) => !o)} style={{ background: 'none', border: 'none', color: '#555', fontSize: 12, cursor: 'pointer', padding: 0 }}>
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
</button>
{rawOpen && (
<div style={{ marginTop: 6 }}>
{editingRaw ? (
<div>
<textarea
aria-label="원문 편집"
value={draftRaw}
onChange={(e) => setDraftRaw(e.target.value)}
style={{ width: '100%', minHeight: 80, fontSize: 12, fontFamily: 'inherit', padding: 8, border: '1px solid #ddd', borderRadius: 4, boxSizing: 'border-box' }}
/>
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => setEditingRaw(false)} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
<button onClick={() => { void saveRaw(); }} style={{ background: '#0a4b80', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
</div>
</div>
) : (
<>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
{local.rawText}
</pre>
<div style={{ marginTop: 4, display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => setShowRevisions(true)} style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12, padding: 0 }}></button>
<button onClick={() => { setDraftRaw(local.rawText); setEditingRaw(true); }} style={{ background: 'none', border: '1px solid #ccc', color: '#444', cursor: 'pointer', fontSize: 12, padding: '3px 10px', borderRadius: 4 }}></button>
</div>
</>
)}
</div>
)}
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-end',
alignItems: 'center'
}}
>
{/* 이동 버튼 — 클릭 시 MoveStatusModal 진입.
사유 입력 + AI 자동 분류 + 수동 status 선택 한 곳에서 처리. */}
<button
onClick={() => setMoveOpen(true)}
aria-label="이동"
style={{
background: 'none',
border: '1px solid #ccc',
color: '#444',
cursor: 'pointer',
fontSize: 12,
padding: '4px 10px',
borderRadius: 4
}}
>
</button>
{/* trash mode 만 영구 삭제 + 복구 보존 (휴지통 단독 액션). */}
{isTrash && (
<>
<button
onClick={onRestore}
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🔄
</button>
<button
onClick={onPermanentDelete}
style={{
background: 'none', border: '1px solid #c93030', color: '#c93030',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🗑
</button>
</>
)}
</div>
</div>
{moveOpen && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
currentStatus={local.status}
onClose={() => setMoveOpen(false)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/완료/보관/휴지통 view 의 list 가 status 별로 필터되므로 status 변경 시 onDeleted 호출.
if (newStatus !== local.status) onDeleted?.();
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
setMoveOpen(false);
}}
/>
)}
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}
onClose={() => setShowRevisions(false)}
onRestored={(newRawText) => {
const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() };
setLocal(updated);
onUpdated(updated);
}}
/>
)}
</div>
);
}