Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713553a038 | ||
|
|
d3cf018f62 | ||
|
|
f676c1638e |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -3,6 +3,73 @@
|
||||
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
||||
|
||||
## [0.3.12] — 2026-05-12
|
||||
|
||||
이미지 AI 처리 실패 fix. vision model 의 응답이 strict JSON 이 아닌 경우 (markdown fence / prose 섞임) 가 흔해 schema parse 단계에서 throw → `ai_status='failed'` 도달.
|
||||
|
||||
### 수정
|
||||
|
||||
- **Vision model 응답 JSON loose parse (P1).** `LocalOllamaProvider.generate` 의 `JSON.parse` 가 strict 라 vision-tuned 모델의 markdown 코드 펜스 / 앞뒤 prose 응답에서 throw. `parseJsonLoose` 헬퍼 추가 — 첫 `{` ~ 마지막 `}` substring 추출 fallback. 실패 시 raw response 200자 snippet 포함한 에러 throw (디버깅 가시화).
|
||||
- **Vision prompt 강화 (P1).** `buildVisionPrompt` 가 "title 한국어로 요약" 만 명시 → schema 의 KOREAN_REGEX/KEBAB_REGEX 강제와 불일치. 규칙 4건 명시: title 한국어 60자, summary 3줄, tags 영문 kebab-case 3개, due_date ISO 또는 null. "markdown 코드 펜스 금지" 명시로 JSON-only 출력 강제.
|
||||
|
||||
알려진 한계:
|
||||
- `gemma4:26b` 같이 Ollama 에 실제로 release 안 된 모델명 사용 시 healthCheck 통과해도 generate 가 unknown model 로 throw. 모델 설치 여부는 사용자가 `ollama list` 확인 필요.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 → **752 PASS** (+2: markdown fence 추출 + prose 추출)
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.11 인스톨러 위에 v0.3.12 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.11] — 2026-05-12
|
||||
|
||||
붙여넣은 이미지 / 저장된 이미지가 양쪽 창에서 표시 안 되던 CSP 누락 hotfix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **QuickCapture: paste 이미지 thumbnail 렌더 실패.** `quickcapture/index.html` 의 CSP 가 `img-src` 미지정 → `default-src 'self'` fallback → `URL.createObjectURL` 의 `blob:` URL 차단. `img-src 'self' data: blob:` 추가.
|
||||
- **Inbox: 저장된 노트 이미지 렌더 실패.** `inbox/index.html` 의 CSP `img-src 'self' data: blob: file:` 가 `inkling-media:` 미허용 → `NoteCard` 의 `<img src="inkling-media://media/..." />` 차단 (custom protocol 자체는 main 에서 등록됐지만 renderer CSP 별도). `inkling-media:` 추가.
|
||||
|
||||
v0.3.0 (Cut A 이미지 첨부) 이후 양쪽 창에서 paste/render 가 잠재적으로 깨져있던 회귀. 사용자가 dogfood 중 발견.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 PASS (CSP meta tag 만 변경 — 코드 path 영향 없음)
|
||||
- typecheck 0 errors
|
||||
- 신규 npm dependency 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.10 인스톨러 위에 v0.3.11 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.10] — 2026-05-12
|
||||
|
||||
macOS fullscreen 환경에서 QuickCapture 핫키 (Cmd+Shift+J) 가 작동하지만 강제로 홈 데스크탑으로 Space 전환 후 표시되던 버그 fix.
|
||||
|
||||
### 수정
|
||||
|
||||
- **macOS fullscreen Space 위에 QuickCapture 표시 (P1).** 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시 → 사용자가 다른 앱 fullscreen 중에 핫키 누르면 macOS 가 강제로 Space 전환 → 사용자 흐름 단절. `quickCaptureWindow.ts` 에 darwin 분기 추가:
|
||||
- `type: 'panel'` — fullscreen Space 위에 floating 가능한 macOS native panel
|
||||
- `setAlwaysOnTop(true, 'screen-saver')` — fullscreen app 위에 띄울 수 있는 가장 높은 level
|
||||
- `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` — 현재 Space (fullscreen 포함) 에 함께 표시, Space 전환 차단
|
||||
|
||||
Windows / Linux 동작은 변경 없음 (darwin 분기만).
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 750 PASS (변경 없음 — main window 코드는 단위 테스트 대상 아님)
|
||||
- typecheck 0 errors (src)
|
||||
- 신규 npm dependency 0
|
||||
- macOS 사용자 수동 검증 완료
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.3.9 인스톨러 위에 v0.3.10 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
|
||||
|
||||
## [0.3.9] — 2026-05-11
|
||||
|
||||
v0.3.8 audit 의 미수정 edge case 3건 완료. AI 처리 흐름의 사용자 unblock path + FTS5 query 안전성.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.12",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.12",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -13,6 +13,22 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other'
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
|
||||
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
|
||||
* 추출해서 JSON.parse 재시도. 실패하면 raw response 일부 포함한 에러 throw (디버깅용).
|
||||
*/
|
||||
function parseJsonLoose(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
||||
const first = raw.indexOf('{');
|
||||
const last = raw.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) {
|
||||
const slice = raw.slice(first, last + 1);
|
||||
try { return JSON.parse(slice); } catch { /* fall through */ }
|
||||
}
|
||||
throw new Error(`unparseable response: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
|
||||
}
|
||||
|
||||
export interface LocalOllamaOptions {
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
@@ -70,9 +86,8 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
}
|
||||
const responseBody = (await res.body.json()) as { response?: string };
|
||||
if (!responseBody.response) throw new Error('missing response field');
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(responseBody.response); }
|
||||
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
|
||||
// v0.3.11 — vision model 응답이 markdown fence / prose 섞인 경우 fallback 추출.
|
||||
const parsed = parseJsonLoose(responseBody.response);
|
||||
return parseAiResponse(parsed);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -4,13 +4,23 @@ export function buildVisionPrompt(
|
||||
dueCandidates: string[],
|
||||
vocab: string[]
|
||||
): string {
|
||||
// v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로만 따르는 경우가
|
||||
// 잦음 (특히 gemma3 vision). title 한국어 + JSON only 를 prompt 에서 명시 강조,
|
||||
// markdown fence 금지 표기로 schema parse 통과율 개선.
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
규칙:
|
||||
- "title" 은 반드시 한국어로 (영어 금지). 60자 이내.
|
||||
- "summary" 는 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함.
|
||||
- "tags" 는 영문 kebab-case (예: meeting-notes), 최대 3개. 한국어 태그 금지.
|
||||
- "due_date" 는 ISO 형식 YYYY-MM-DD 또는 null.
|
||||
|
||||
오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지.
|
||||
출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null}
|
||||
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
|
||||
@@ -16,16 +16,29 @@ export function createQuickCaptureWindow(): BrowserWindowType {
|
||||
const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x);
|
||||
const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y);
|
||||
|
||||
// v0.3.10 — macOS fullscreen Space 위에 quick capture 띄우기.
|
||||
// 기본 BrowserWindow 는 첫 생성된 Space (홈 데스크탑) 에만 표시되므로
|
||||
// 사용자가 다른 앱 fullscreen 중일 때 macOS 가 강제 Space 전환 → 사용자 경험 깨짐.
|
||||
// 'panel' 타입 + 'screen-saver' level + visibleOnFullScreen 조합으로 현재 Space 위에 overlay.
|
||||
const isMac = process.platform === 'darwin';
|
||||
win = new BrowserWindow({
|
||||
width: W, height: H, x, y,
|
||||
frame: false, show: false, alwaysOnTop: true,
|
||||
skipTaskbar: true, resizable: false,
|
||||
...(isMac ? { type: 'panel' as const } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
if (isMac) {
|
||||
// 'screen-saver' level — fullscreen app 위에 띄울 수 있는 가장 높은 level.
|
||||
// visibleOnFullScreen: true — 현재 fullscreen Space 에 함께 표시 (Space 전환 안 함).
|
||||
win.setAlwaysOnTop(true, 'screen-saver');
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`);
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file: inkling-media:" />
|
||||
<title>Inkling</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:" />
|
||||
<title>Inkling Capture</title>
|
||||
<style>
|
||||
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; overflow: hidden; }
|
||||
|
||||
@@ -51,7 +51,24 @@ describe('LocalOllamaProvider', () => {
|
||||
});
|
||||
await expect(
|
||||
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
|
||||
).rejects.toThrow(/json/i);
|
||||
).rejects.toThrow(/unparseable/i);
|
||||
});
|
||||
|
||||
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
|
||||
// vision model 이 ```json ... ``` 형태로 응답하는 경우 fallback 으로 추출.
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: '```json\n{"title":"회의","summary":"a\\nb\\nc","tags":["meet"]}\n```'
|
||||
});
|
||||
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||
expect(r.title).toBe('회의');
|
||||
});
|
||||
|
||||
it('v0.3.11 — generate extracts JSON when prose 가 앞뒤로 섞임', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: 'Here is the response:\n{"title":"회의","summary":"a\\nb\\nc","tags":[]}\nDone.'
|
||||
});
|
||||
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||
expect(r.title).toBe('회의');
|
||||
});
|
||||
|
||||
it('generate aborts on timeout', async () => {
|
||||
|
||||
Reference in New Issue
Block a user