fix(suggestion): place overlay in DIPs, size it to content, and harden suggestion sessions
This commit is contained in:
parent
75e053d72f
commit
9e5b94ced2
18 changed files with 2178 additions and 558 deletions
150
packages/core/__tests__/popup-placement.test.ts
Normal file
150
packages/core/__tests__/popup-placement.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
anchorFloatingPanel,
|
||||
asDipPoint,
|
||||
asDipRect,
|
||||
fitAnchoredPanel,
|
||||
placeAnchoredPanel,
|
||||
placeNearCursor,
|
||||
type DipPoint,
|
||||
type DipRect,
|
||||
type PhysicalRect,
|
||||
type ScreenGeometryPort,
|
||||
type UiRect
|
||||
} from '../src/popup-placement'
|
||||
|
||||
/**
|
||||
* 두 모니터 가짜 포트 — 물리 픽셀 좌표계에서 모니터마다 배율이 다르다.
|
||||
* A: 물리 (0,0) 1920x1080 @150% → DIP (0,0) 1280x720 (작업 영역 높이 680)
|
||||
* B: 물리 (1920,0) 1920x1080 @100% → DIP (1280,0) 1920x1080 (작업 영역 높이 1040)
|
||||
*/
|
||||
function twoMonitorPort(cursor: { x: number; y: number } = { x: 10, y: 10 }): ScreenGeometryPort {
|
||||
const monitors = [
|
||||
{ physical: { x: 0, y: 0, width: 1920, height: 1080 }, dip: { x: 0, y: 0, width: 1280, height: 720 }, scale: 1.5, workHeight: 680 },
|
||||
{ physical: { x: 1920, y: 0, width: 1920, height: 1080 }, dip: { x: 1280, y: 0, width: 1920, height: 1080 }, scale: 1, workHeight: 1040 }
|
||||
]
|
||||
const monitorOfPhysical = (rect: UiRect) =>
|
||||
monitors.find((m) => rect.x >= m.physical.x && rect.x < m.physical.x + m.physical.width) ?? monitors[0]
|
||||
const monitorOfDip = (point: { x: number; y: number }) =>
|
||||
monitors.find((m) => point.x >= m.dip.x && point.x < m.dip.x + m.dip.width) ?? monitors[0]
|
||||
return {
|
||||
toDip(rect: PhysicalRect): DipRect {
|
||||
const m = monitorOfPhysical(rect)
|
||||
return asDipRect({
|
||||
x: Math.round(m.dip.x + (rect.x - m.physical.x) / m.scale),
|
||||
y: Math.round(m.dip.y + (rect.y - m.physical.y) / m.scale),
|
||||
width: Math.round(rect.width / m.scale),
|
||||
height: Math.round(rect.height / m.scale)
|
||||
})
|
||||
},
|
||||
workAreaNear(point: DipPoint): DipRect {
|
||||
const m = monitorOfDip(point)
|
||||
return asDipRect({ x: m.dip.x, y: m.dip.y, width: m.dip.width, height: m.workHeight })
|
||||
},
|
||||
cursor: () => asDipPoint(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
const SIZE = { width: 460, height: 208 }
|
||||
|
||||
describe('placeAnchoredPanel — 물리 픽셀 앵커를 DIP 로 한 번만 바꾼다', () => {
|
||||
it('150% 모니터에서 캐럿 (600,400) 물리 → DIP (400,267) 바로 아래에 붙는다', () => {
|
||||
const caret: PhysicalRect = { x: 600, y: 400, width: 2, height: 30 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP 캐럿: x=400, y=267, h=20 → 아래 6px
|
||||
expect(placement).toMatchObject({ x: 400, y: 267 + 20 + 6, grow: 'down', maxHeight: 208, width: 460 })
|
||||
})
|
||||
|
||||
it('150% 모니터에서 캐럿 (1500,900) 물리는 작업 영역 밖으로 읽히지 않고 캐럿 위로 뒤집힌다', () => {
|
||||
const caret: PhysicalRect = { x: 1500, y: 900, width: 2, height: 30 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP 캐럿: x=1000, y=600, h=20 → 아래는 680 을 넘으므로 위(600-6-208=386)
|
||||
expect(placement.y).toBe(386)
|
||||
expect(placement.grow).toBe('up')
|
||||
// 가로는 작업 영역 오른쪽(1280-460=820)으로 클램프될 뿐 모서리 구석에 박히지 않는다
|
||||
expect(placement.x).toBe(820)
|
||||
})
|
||||
|
||||
it('두 번째(100%) 모니터의 캐럿은 그 모니터의 작업 영역을 쓴다', () => {
|
||||
const caret: PhysicalRect = { x: 2000, y: 900, width: 2, height: 20 }
|
||||
const placement = placeAnchoredPanel(twoMonitorPort(), caret, 'caret', SIZE)
|
||||
// DIP: x = 1280 + 80 = 1360, y = 900 → 아래(926+208=1134 > 1040) 불가 → 위 686
|
||||
expect(placement).toMatchObject({ x: 1360, y: 900 - 6 - 208, grow: 'up' })
|
||||
})
|
||||
|
||||
it('앵커가 없으면 DIP 커서 기준이다', () => {
|
||||
const placement = placeAnchoredPanel(twoMonitorPort({ x: 100, y: 100 }), null, null, SIZE)
|
||||
expect(placement).toMatchObject({ x: 100, y: 106, grow: 'down' })
|
||||
})
|
||||
|
||||
it('100% 배율(항등 포트)에서는 기존 anchorFloatingPanel 결과와 같다', () => {
|
||||
const identity: ScreenGeometryPort = {
|
||||
toDip: (rect) => asDipRect(rect),
|
||||
workAreaNear: () => asDipRect({ x: 0, y: 0, width: 1920, height: 1040 }),
|
||||
cursor: () => asDipPoint({ x: 5, y: 5 })
|
||||
}
|
||||
const caret = { x: 700, y: 500, width: 2, height: 18 }
|
||||
const placement = placeAnchoredPanel(identity, caret, 'caret', SIZE)
|
||||
const legacy = anchorFloatingPanel(caret, 'caret', { x: 5, y: 5 }, SIZE, { x: 0, y: 0, width: 1920, height: 1040 })
|
||||
expect({ x: placement.x, y: placement.y }).toEqual(legacy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitAnchoredPanel — 창을 그려진 패널 높이로 줄인다', () => {
|
||||
it('아래로 붙은 패널은 위 모서리를 고정한다', () => {
|
||||
expect(fitAnchoredPanel({ x: 10, y: 100, width: 460, maxHeight: 208, grow: 'down' }, 54)).toEqual({
|
||||
x: 10,
|
||||
y: 100,
|
||||
width: 460,
|
||||
height: 54
|
||||
})
|
||||
})
|
||||
|
||||
it('위로 뒤집힌 패널은 앵커 쪽(아래) 모서리를 고정한다', () => {
|
||||
expect(fitAnchoredPanel({ x: 10, y: 386, width: 460, maxHeight: 208, grow: 'up' }, 54)).toEqual({
|
||||
x: 10,
|
||||
y: 386 + 208 - 54,
|
||||
width: 460,
|
||||
height: 54
|
||||
})
|
||||
})
|
||||
|
||||
it('최대 높이를 넘지 않는다', () => {
|
||||
expect(fitAnchoredPanel({ x: 0, y: 0, width: 460, maxHeight: 208, grow: 'down' }, 500).height).toBe(208)
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeNearCursor — 커서 기준 팝업 배치', () => {
|
||||
const workArea = { x: 0, y: 0, width: 1366, height: 728 }
|
||||
|
||||
it('커서 위 20px, 가로 가운데', () => {
|
||||
expect(placeNearCursor({ x: 600, y: 500 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 280 })
|
||||
})
|
||||
|
||||
it('위 공간이 없으면 아래로 뒤집는다', () => {
|
||||
expect(placeNearCursor({ x: 600, y: 100 }, { width: 340, height: 200 }, workArea)).toEqual({ x: 430, y: 120 })
|
||||
})
|
||||
|
||||
it('아래로 뒤집힌 큰 팝업도 작업 영역 아래로 잘리지 않는다 (기록/명령 팝업 회귀)', () => {
|
||||
const position = placeNearCursor({ x: 600, y: 300 }, { width: 380, height: 500 }, workArea)
|
||||
expect(position.y + 500).toBeLessThanOrEqual(728)
|
||||
expect(position.y).toBe(228)
|
||||
})
|
||||
|
||||
it('왼쪽/오른쪽 가장자리에서 클램프한다', () => {
|
||||
expect(placeNearCursor({ x: 5, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(0)
|
||||
expect(placeNearCursor({ x: 1360, y: 500 }, { width: 340, height: 100 }, workArea).x).toBe(1366 - 340)
|
||||
})
|
||||
|
||||
it('음수 좌표 모니터(주 모니터 왼쪽)에서도 그 작업 영역 안에 둔다', () => {
|
||||
const left = { x: -1920, y: -200, width: 1920, height: 1040 }
|
||||
expect(placeNearCursor({ x: -1910, y: -150 }, { width: 320, height: 110 }, left)).toEqual({ x: -1920, y: -130 })
|
||||
})
|
||||
|
||||
it('팝업이 작업 영역보다 크면 왼쪽 위 모서리에 둔다', () => {
|
||||
expect(placeNearCursor({ x: 50, y: 50 }, { width: 400, height: 300 }, { x: 0, y: 0, width: 300, height: 200 })).toEqual({
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
202
packages/core/__tests__/suggestion-redteam-r2-3.test.ts
Normal file
202
packages/core/__tests__/suggestion-redteam-r2-3.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildLocalSuggestionCandidateEntries,
|
||||
buildLocalSuggestionCandidates,
|
||||
decideInFlightGeneration,
|
||||
decideSuggestionStep,
|
||||
extractPhrases,
|
||||
joinSuggestion,
|
||||
shouldPersistSuggestionPrefix,
|
||||
type SuggestionPolicyInput,
|
||||
type SuggestionStepInput
|
||||
} from '../src/input-intelligence'
|
||||
import { splitSentences } from '../src/personal-graph'
|
||||
import { countLexicalWords } from '../src/text-segmentation'
|
||||
|
||||
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
|
||||
return {
|
||||
enabled: true,
|
||||
modelAvailable: true,
|
||||
overlayVisible: false,
|
||||
composing: false,
|
||||
hasSelection: false,
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
appName: 'Slack.exe',
|
||||
excludedApps: [],
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
prefix: '오늘 회의 결과를 정리해서',
|
||||
idleMs: 2000,
|
||||
triggerDelayMs: 600,
|
||||
minPrefixChars: 8,
|
||||
sinceLastRequestMs: 60_000,
|
||||
minIntervalMs: 5000,
|
||||
requestsThisMinute: 0,
|
||||
maxRequestsPerMinute: 6,
|
||||
requestsToday: 0,
|
||||
dailyBudget: 500,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function step(overrides: Partial<SuggestionStepInput> = {}, policyOverrides: Partial<SuggestionPolicyInput> = {}) {
|
||||
return decideSuggestionStep({
|
||||
policy: policy(policyOverrides),
|
||||
sessionVisible: false,
|
||||
sessionPrefix: '',
|
||||
coolingDown: false,
|
||||
userDismissedQuiet: false,
|
||||
lastRequestedPrefix: '',
|
||||
modelWarm: true,
|
||||
warmUpBackoff: false,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('joinSuggestion — 수락한 후보가 앞 글자에 들러붙지 않는다', () => {
|
||||
it.each([
|
||||
['I sent the report.', 'Let me know if you have questions.', ' Let me know if you have questions.'],
|
||||
['Let us meet at', '3 pm in the lobby', ' 3 pm in the lobby'],
|
||||
['오늘 회의에서', '논의한 내용을 공유드립니다', ' 논의한 내용을 공유드립니다'],
|
||||
['회의 끝났습니다.', '다음 주에 뵙겠습니다', ' 다음 주에 뵙겠습니다']
|
||||
])('%s + %s', (prefix, candidate, expected) => {
|
||||
expect(joinSuggestion(prefix, candidate)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['이미 공백을 쳤으면 그대로', 'I sent the report. ', 'Let me know'],
|
||||
['개행 뒤도 그대로', 'Hello\n', 'World'],
|
||||
['빈 접두', '', 'Hello there'],
|
||||
['여는 괄호 뒤', 'see (', 'attached file)'],
|
||||
['경로 구분자 뒤', 'cd src/', 'main'],
|
||||
['닫는 구두점으로 시작', 'Thanks', ', see you soon'],
|
||||
['일본어 (띄어 쓰지 않음)', '今日は会議があります。', '明日の予定を確認してください'],
|
||||
['중국어', '我们明天', '开会讨论项目进度'],
|
||||
['태국어', 'สวัสดี', 'ครับ']
|
||||
])('%s — 공백을 넣지 않는다', (_case, prefix, candidate) => {
|
||||
expect(joinSuggestion(prefix, candidate)).toBe(candidate)
|
||||
})
|
||||
|
||||
it('단어 중간에서 이어지는 나머지(attach)는 붙인다', () => {
|
||||
expect(joinSuggestion('Thank yo', 'u for the update', 'attach')).toBe('u for the update')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLocalSuggestionCandidateEntries — 이음새 정보를 잃지 않는다', () => {
|
||||
const hints = (phraseHints: string[]) => ({ continuationHints: [], relatedHints: [], phraseHints })
|
||||
|
||||
it('단어 경계에서 겹친 나머지는 separate, 단어 중간이면 attach', () => {
|
||||
expect(buildLocalSuggestionCandidateEntries('Thank you', hints(['Thank you for the update']))).toEqual([
|
||||
{ text: 'for the update', attachToPrefix: false }
|
||||
])
|
||||
expect(buildLocalSuggestionCandidateEntries('Thank yo', hints(['Thank you for the update']))).toEqual([
|
||||
{ text: 'u for the update', attachToPrefix: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('기존 문자열 API 는 그대로다', () => {
|
||||
expect(buildLocalSuggestionCandidates('Thank you', hints(['Thank you for the update']))).toEqual(['for the update'])
|
||||
})
|
||||
|
||||
it('이음새까지 합치면 올바른 문장이 된다', () => {
|
||||
const [separate] = buildLocalSuggestionCandidateEntries('Thank you', hints(['Thank you for the update']))
|
||||
expect(`Thank you${joinSuggestion('Thank you', separate.text, separate.attachToPrefix ? 'attach' : 'separate')}`).toBe(
|
||||
'Thank you for the update'
|
||||
)
|
||||
const [attach] = buildLocalSuggestionCandidateEntries('Thank yo', hints(['Thank you for the update']))
|
||||
expect(`Thank yo${joinSuggestion('Thank yo', attach.text, attach.attachToPrefix ? 'attach' : 'separate')}`).toBe(
|
||||
'Thank you for the update'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideInFlightGeneration — 진행 중 생성도 세션 규칙을 따른다', () => {
|
||||
it('요청 뒤 몇 글자라도 자라면 stale (12자 미만 성장도)', () => {
|
||||
expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘 저녁까지')).toBe('stale')
|
||||
})
|
||||
|
||||
it('같은 접두 · 뒤 공백만 추가 · IME 마지막 글자 조합은 keep', () => {
|
||||
expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘')).toBe('keep')
|
||||
expect(decideInFlightGeneration('회의 자료는 오늘', '회의 자료는 오늘 ')).toBe('keep')
|
||||
expect(decideInFlightGeneration('회의 자료는 하', '회의 자료는 한')).toBe('keep')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideSuggestionStep — 서버가 없을 때 끝나지 않는 "준비 중" 을 띄우지 않는다', () => {
|
||||
it('준비 작업이 없으면(modelStarting=false) record', () => {
|
||||
expect(step({ modelStarting: false }, { modelAvailable: false })).toMatchObject({
|
||||
reason: 'model-unavailable',
|
||||
tryLocalMemory: true,
|
||||
then: 'record'
|
||||
})
|
||||
})
|
||||
|
||||
it('워밍업 루프가 서버를 기다리는 중이면 "준비 중" 을 보여준다', () => {
|
||||
expect(step({ modelStarting: true }, { modelAvailable: false })).toMatchObject({
|
||||
reason: 'model-unavailable',
|
||||
then: 'show-warming'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPersistSuggestionPrefix — 입력 원문 저장은 학습 동의 규칙을 따른다', () => {
|
||||
it('학습 동의가 없으면 저장하지 않는다', () => {
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: false, appName: 'Slack.exe' })).toBe(false)
|
||||
})
|
||||
|
||||
it('학습 제외 앱(터미널·에디터)은 동의가 있어도 저장하지 않는다', () => {
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'WindowsTerminal.exe' })).toBe(false)
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'Code.exe' })).toBe(false)
|
||||
})
|
||||
|
||||
it('동의가 있고 일반 앱이면 저장한다', () => {
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: 'Slack.exe' })).toBe(true)
|
||||
expect(shouldPersistSuggestionPrefix({ learnTypedText: true, appName: null })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('띄어쓰기 없는 언어도 학습된다', () => {
|
||||
const ja = '今日は会議があります。明日の予定を確認してください。'
|
||||
const zh = '我们明天开会讨论项目进度。请确认你的日程安排。'
|
||||
const th = 'วันนี้เรามีประชุมที่สำนักงาน\nพรุ่งนี้ต้องส่งรายงานให้หัวหน้า'
|
||||
|
||||
it('splitSentences 가 일본어·중국어·태국어 문장을 버리지 않는다', () => {
|
||||
expect(splitSentences(ja)).toEqual(['今日は会議があります', '明日の予定を確認してください'])
|
||||
expect(splitSentences(zh)).toEqual(['我们明天开会讨论项目进度', '请确认你的日程安排'])
|
||||
expect(splitSentences(th)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('extractPhrases 도 같은 문장을 문구로 뽑는다', () => {
|
||||
expect(extractPhrases(ja)).toEqual(['今日は会議があります', '明日の予定を確認してください'])
|
||||
expect(extractPhrases(zh, 4, 6)).toEqual(['我们明天开会讨论项目进度', '请确认你的日程安排'])
|
||||
})
|
||||
|
||||
it('띄어 쓰는 언어의 기존 단어 수는 그대로다', () => {
|
||||
expect(countLexicalWords('Hello there friend')).toBe(3)
|
||||
expect(countLexicalWords('안녕 하세요')).toBe(2)
|
||||
expect(countLexicalWords('짧음')).toBe(1)
|
||||
expect(splitSentences('오늘 회의는 여기서 끝. 짧음. 내일 일정을 공유드릴게요!')).toEqual([
|
||||
'오늘 회의는 여기서 끝',
|
||||
'내일 일정을 공유드릴게요'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Intl.Segmenter 가 없는 런타임(Hermes 등)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('글자 수로 어림해 문장을 버리지 않는다', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubGlobal('Intl', { ...Intl, Segmenter: undefined })
|
||||
const segmentation = await import('../src/text-segmentation')
|
||||
const graph = await import('../src/personal-graph')
|
||||
expect(segmentation.getWordSegmenter()).toBeNull()
|
||||
expect(segmentation.countLexicalWords('今日は会議があります')).toBeGreaterThanOrEqual(2)
|
||||
expect(segmentation.countLexicalWords('短い')).toBe(1)
|
||||
expect(graph.splitSentences('今日は会議があります。明日の予定を確認してください。')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -22,15 +22,20 @@ import {
|
|||
SENTENCE_TERMINATORS,
|
||||
SUGGESTION_MAX_OUTPUT_CHARS,
|
||||
normalizeRequestPrefix,
|
||||
normalizeSessionPrefix
|
||||
normalizeSessionPrefix,
|
||||
type SuggestionJoint
|
||||
} from './suggestion-text'
|
||||
import { NON_PROSE_GLYPH_PATTERN } from './terminal-prompt'
|
||||
import type { AnchorKind, PhysicalRect } from './popup-placement'
|
||||
import { countLexicalWords } from './text-segmentation'
|
||||
|
||||
export {
|
||||
SUGGESTION_CONTEXT_MAX_CHARS,
|
||||
SUGGESTION_MAX_OUTPUT_CHARS,
|
||||
buildLocalSuggestionCandidateEntries,
|
||||
buildLocalSuggestionCandidates,
|
||||
endsSentence,
|
||||
joinSuggestion,
|
||||
normalizeRequestPrefix,
|
||||
normalizeSessionPrefix,
|
||||
normalizeSuggestionText,
|
||||
|
|
@ -38,29 +43,27 @@ export {
|
|||
sanitizeSuggestionLine,
|
||||
stripModelArtifacts
|
||||
} from './suggestion-text'
|
||||
export type { LocalSuggestionHints } from './suggestion-text'
|
||||
export type { LocalSuggestionCandidate, LocalSuggestionHints, SuggestionJoint } from './suggestion-text'
|
||||
export { extractTerminalPromptPrefix } from './terminal-prompt'
|
||||
|
||||
// ============================================================
|
||||
// 기하
|
||||
// 기하 — 정본은 popup-placement.ts (좌표 공간 · 배치 정책). 기존 import 경로를 유지한다.
|
||||
// ============================================================
|
||||
|
||||
export interface UiRect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지.
|
||||
*
|
||||
* 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는
|
||||
* elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그
|
||||
* 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을
|
||||
* 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다.
|
||||
*/
|
||||
export type AnchorKind = 'caret' | 'element' | null
|
||||
export {
|
||||
anchorFloatingPanel,
|
||||
anchorFloatingPanelInDips,
|
||||
asDipPoint,
|
||||
asDipRect
|
||||
} from './popup-placement'
|
||||
export type {
|
||||
AnchorKind,
|
||||
DipPoint,
|
||||
DipRect,
|
||||
PhysicalRect,
|
||||
ScreenGeometryPort,
|
||||
UiRect
|
||||
} from './popup-placement'
|
||||
|
||||
/** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */
|
||||
export const MOUSE_MOVE_NOISE_FLOOR_PX = 2
|
||||
|
|
@ -70,99 +73,6 @@ export function manhattanDistance(from: { x: number; y: number }, to: { x: numbe
|
|||
return Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* 오버레이 커 좌표 계산.
|
||||
*
|
||||
* 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과
|
||||
* 같은 관례다. 아래 공간이 없으면 위로 뒤집는다.
|
||||
* 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 —
|
||||
* 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다.
|
||||
* 마지막으로 항상 작업영역 안으로 클램프한다.
|
||||
*/
|
||||
export function anchorFloatingPanel(
|
||||
anchor: UiRect | null,
|
||||
anchorKind: AnchorKind,
|
||||
cursor: { x: number; y: number },
|
||||
size: { width: number; height: number },
|
||||
workArea: UiRect,
|
||||
gap = 6
|
||||
): { x: number; y: number } {
|
||||
if (anchorKind === 'element' && anchor && anchor.width >= 0 && anchor.height >= 0) {
|
||||
return anchorOutsideElement(anchor, size, workArea, gap)
|
||||
}
|
||||
|
||||
const rect: UiRect =
|
||||
anchor && anchor.width >= 0 && anchor.height >= 0
|
||||
? anchor
|
||||
: { x: cursor.x, y: cursor.y, width: 0, height: 0 }
|
||||
|
||||
const anchorBottom = rect.y + Math.max(rect.height, 0)
|
||||
const desiredY = anchorBottom + gap
|
||||
const fitsBelow = desiredY + size.height <= workArea.y + workArea.height
|
||||
const above = rect.y - gap - size.height
|
||||
const y = fitsBelow ? desiredY : above >= workArea.y ? above : desiredY
|
||||
|
||||
return {
|
||||
x: clamp(rect.x, workArea.x, workArea.x + workArea.width - size.width),
|
||||
y: clamp(y, workArea.y, workArea.y + workArea.height - size.height)
|
||||
}
|
||||
}
|
||||
|
||||
function fitsInWorkArea(
|
||||
x: number,
|
||||
y: number,
|
||||
size: { width: number; height: number },
|
||||
workArea: UiRect
|
||||
): boolean {
|
||||
return (
|
||||
x >= workArea.x &&
|
||||
y >= workArea.y &&
|
||||
x + size.width <= workArea.x + workArea.width &&
|
||||
y + size.height <= workArea.y + workArea.height
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 앵커를 요소 "바깥" 에 배치한다.
|
||||
*
|
||||
* 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을
|
||||
* 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다
|
||||
* (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에
|
||||
* 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다.
|
||||
*/
|
||||
function anchorOutsideElement(
|
||||
element: UiRect,
|
||||
size: { width: number; height: number },
|
||||
workArea: UiRect,
|
||||
gap: number
|
||||
): { x: number; y: number } {
|
||||
const candidates: Array<{ x: number; y: number }> = [
|
||||
{ x: element.x, y: element.y + element.height + gap }, // 아래
|
||||
{ x: element.x, y: element.y - gap - size.height }, // 위
|
||||
{ x: element.x + element.width + gap, y: element.y }, // 오른쪽
|
||||
{ x: element.x - gap - size.width, y: element.y } // 왼쪽
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
x: element.x + element.width - size.width - gap,
|
||||
y: element.y + element.height - size.height - gap
|
||||
}
|
||||
|
||||
return {
|
||||
x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width),
|
||||
y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height)
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (max < min) return min
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 키스트로크 분류 (내용이 아니라 '종류'만 남긴다)
|
||||
// ============================================================
|
||||
|
|
@ -224,7 +134,8 @@ export function isWordBoundaryKey(keyClass: KeyStrokeClass): boolean {
|
|||
* 단어 수.
|
||||
*
|
||||
* 공백 분리 토큰 중 문자/숫자/CJK 를 포함한 것만 센다.
|
||||
* (중국어처럼 공백이 없는 언어는 과소 집계된다 — GAP-SUGGEST-03 에 기록)
|
||||
* (중국어처럼 공백이 없는 언어는 과소 집계된다 — 활동 통계 전용. 학습 필터는
|
||||
* text-segmentation.ts 의 countLexicalWords 로 센다)
|
||||
*/
|
||||
export function countWords(text: string): number {
|
||||
if (!text) return 0
|
||||
|
|
@ -348,8 +259,9 @@ export interface FocusSnapshot {
|
|||
textSource: FocusTextSource
|
||||
text: string
|
||||
caretOffset: number | null
|
||||
caretRect: UiRect | null
|
||||
elementRect: UiRect | null
|
||||
/** 물리 픽셀 (UIA 는 DPI-aware 로 동작한다) — 배치 전에 ScreenGeometryPort 로 DIP 변환 */
|
||||
caretRect: PhysicalRect | null
|
||||
elementRect: PhysicalRect | null
|
||||
windowTitle: string | null
|
||||
appName: string | null
|
||||
processId: number | null
|
||||
|
|
@ -617,6 +529,18 @@ export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: str
|
|||
return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 생성(아직 후보 없음)을 계속 둘지 결정한다 — 세션 규칙과 같은 기준이다.
|
||||
*
|
||||
* 결과는 요청한 접두로 게시되고, 게시된 세션은 성장을 허용하지 않는다(matchesSessionPrefix).
|
||||
* 그래서 요청 뒤 사용자가 몇 글자라도 더 쳤다면 결과는 도착하자마자 stale 로 닫힌다 —
|
||||
* 깜빡임 · 버려지는 모델/채우기 요청 · 옛 접두용 문장 수락을 막으려면 그 즉시 취소한다.
|
||||
* IME 마지막 글자 조합 변화만 예외다.
|
||||
*/
|
||||
export function decideInFlightGeneration(requestedPrefix: string, currentPrefix: string): 'keep' | 'stale' {
|
||||
return matchesSessionPrefix(requestedPrefix, currentPrefix) ? 'keep' : 'stale'
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다.
|
||||
*
|
||||
|
|
@ -675,6 +599,14 @@ export interface SuggestionStepInput {
|
|||
modelWarm: boolean
|
||||
/** 직전 워밍업이 실패해 재시도를 잠시 미루는 중인가 */
|
||||
warmUpBackoff: boolean
|
||||
/**
|
||||
* 모델 서버를 실제로 준비하는 작업(워밍업 루프)이 진행 중인가.
|
||||
*
|
||||
* 서버가 없을 때(model-unavailable) "준비 중" 은 이 작업이 있을 때만 보여준다 — 아무것도
|
||||
* 준비하지 않는데 스피너를 띄우면 끝나지 않는 "준비 중" 이 옛 캐럿 위에 20초씩 반복된다
|
||||
* (Ollama 미설치/중지). 생략하면 예전처럼 "준비 중" 을 보여준다(호환). 서비스는 항상 넘긴다.
|
||||
*/
|
||||
modelStarting?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -723,7 +655,8 @@ export function decideSuggestionStep(input: SuggestionStepInput): SuggestionStep
|
|||
action: 'settle',
|
||||
reason: 'model-unavailable',
|
||||
tryLocalMemory: localMemoryAllowed,
|
||||
then: 'show-warming'
|
||||
// 준비 작업이 없으면 기능은 꺼진 것처럼 조용해야 한다 (설정 문구: "제안이 꺼진 상태로 동작").
|
||||
then: input.modelStarting === false ? 'record' : 'show-warming'
|
||||
}
|
||||
}
|
||||
return { action: 'settle', reason: decision.reason, tryLocalMemory: false, then: 'record' }
|
||||
|
|
@ -816,6 +749,21 @@ export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([
|
|||
'Agent Switchboard'
|
||||
])
|
||||
|
||||
/**
|
||||
* 제안 이력에 입력 문맥(접두) 원문을 남겨도 되는가.
|
||||
*
|
||||
* 접두는 사용자가 친 글 그 자체다 — 학습 동의(inputLearnTypedText)가 있고, 학습 제외 앱
|
||||
* (터미널·에디터·에이전트 허브)이 아닐 때만 저장한다. 텔레메트리 학습과 같은 규칙을 쓴다.
|
||||
* 그 밖에는 원문 대신 빈 문자열을 남겨 이력 개수·수락률 통계만 유지한다.
|
||||
*/
|
||||
export function shouldPersistSuggestionPrefix(input: {
|
||||
learnTypedText: boolean
|
||||
appName: string | null
|
||||
}): boolean {
|
||||
if (!input.learnTypedText) return false
|
||||
return !(input.appName && isAppExcluded(input.appName, LEARNING_EXCLUDED_APPS))
|
||||
}
|
||||
|
||||
/** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */
|
||||
const MIN_LETTER_RATIO = 0.6
|
||||
|
||||
|
|
@ -1119,7 +1067,8 @@ export function extractPhrases(text: string, maxPhrases = 4, minChars = 6): stri
|
|||
const phrase = segment.replace(/\s+/gu, ' ').trim()
|
||||
if (phrase.length < minChars) continue
|
||||
if (phrase.length > SUGGESTION_MAX_OUTPUT_CHARS) continue
|
||||
if (countWords(phrase) < 2) continue
|
||||
// 띄어쓰기 없는 문자 체계(중·일·태)도 문구로 인정한다 — 공백 단어 수로는 항상 1이다.
|
||||
if (countLexicalWords(phrase) < 2) continue
|
||||
if (out.includes(phrase)) continue
|
||||
out.push(phrase)
|
||||
if (out.length >= maxPhrases) break
|
||||
|
|
@ -1170,6 +1119,8 @@ export interface SuggestionCandidate {
|
|||
text: string
|
||||
/** 0 = 최상위 */
|
||||
rank: number
|
||||
/** 접두와의 이음새 — 생략하면 'separate' (joinSuggestion 참고) */
|
||||
joint?: SuggestionJoint
|
||||
}
|
||||
|
||||
/** 현재 제안이 어느 로컬 근거에서 나왔는지 보여 주는 비식별 설명. */
|
||||
|
|
@ -1200,8 +1151,8 @@ export interface SuggestionState {
|
|||
* UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거.
|
||||
*/
|
||||
targetTotal: number
|
||||
/** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */
|
||||
anchor: UiRect | null
|
||||
/** 앵커 rect — 물리 픽셀 (케어렛 → 요소 → 마우스 폴백은 메인이 계산, DIP 변환은 창 배치 한 곳에서) */
|
||||
anchor: PhysicalRect | null
|
||||
/** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */
|
||||
anchorKind: AnchorKind
|
||||
appName: string | null
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
//
|
||||
// 전부 순수 함수로 두어 LLM/DB 없이 검증 가능하게 한다.
|
||||
|
||||
import { countWords } from './input-intelligence'
|
||||
import type { PhraseSource } from './input-intelligence'
|
||||
import { countLexicalWords, getWordSegmenter } from './text-segmentation'
|
||||
|
||||
// 렌더러/프리로드도 같은 계약을 쓴다 (main 모듈을 import 할 수 없다).
|
||||
export type { PhraseSource }
|
||||
|
|
@ -104,18 +104,6 @@ const TERM_SEPARATOR = /[^\p{L}\p{N}\p{M}]+/u
|
|||
/** 띄어쓰기 없이 쓰는 문자 체계 — 경계 분리만으로는 문장 전체가 한 토큰이 된다. */
|
||||
const UNSPACED_SCRIPT = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u
|
||||
|
||||
let wordSegmenter: Intl.Segmenter | null | undefined
|
||||
|
||||
/** 런타임이 지원할 때만 단어 분할기를 만든다 (Hermes 등에는 없을 수 있다). */
|
||||
function getWordSegmenter(): Intl.Segmenter | null {
|
||||
if (wordSegmenter !== undefined) return wordSegmenter
|
||||
wordSegmenter =
|
||||
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
: null
|
||||
return wordSegmenter
|
||||
}
|
||||
|
||||
/** 띄어쓰기 없는 문자 체계의 토큰을 사전 기반 단어로 나눈다. 분할기가 없으면 그대로 둔다. */
|
||||
function segmentUnspacedToken(token: string): string[] {
|
||||
if (!UNSPACED_SCRIPT.test(token)) return [token]
|
||||
|
|
@ -164,12 +152,17 @@ export function extractTerms(text: string, limit = 8): string[] {
|
|||
.map(([token]) => token)
|
||||
}
|
||||
|
||||
/** 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다. */
|
||||
/**
|
||||
* 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다.
|
||||
*
|
||||
* 단어 수는 countLexicalWords 로 센다 — 공백으로만 세면 띄어쓰기 없는 중국어·일본어·태국어
|
||||
* 문장이 모두 1단어가 되어 그 언어 사용자의 문장이 하나도 학습되지 않는다.
|
||||
*/
|
||||
export function splitSentences(text: string): string[] {
|
||||
return text
|
||||
.split(/[.!?。!?…\n]+/u)
|
||||
.map((segment) => segment.replace(/\s+/gu, ' ').trim())
|
||||
.filter((segment) => segment.length >= 4 && countWords(segment) >= 2)
|
||||
.filter((segment) => segment.length >= 4 && countLexicalWords(segment) >= 2)
|
||||
}
|
||||
|
||||
/** 한 텍스트 안에서 인접한 문장 쌍 (follows 엣지의 근거). */
|
||||
|
|
|
|||
269
packages/core/src/popup-placement.ts
Normal file
269
packages/core/src/popup-placement.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// packages/core — 팝업/오버레이 배치 기하 정본.
|
||||
//
|
||||
// 두 가지를 담는다:
|
||||
// 1) 좌표 공간 — UIA(사이드카)는 물리 픽셀, Electron screen API 는 DIP 를 쓴다. 둘을 같은
|
||||
// `UiRect` 로 섞으면 125%/150% 배율에서 오버레이가 캐럿에서 멀리 떨어지거나 작업 영역
|
||||
// 모서리에 붙는다. 그래서 공간을 타입으로 표기하고(PhysicalRect/DipRect), 변환은
|
||||
// ScreenGeometryPort 한 곳(Electron 어댑터)에서만 한다 — core 는 좌표를 만든 OS API 를 모른다.
|
||||
// 2) 배치 정책 — 캐럿/요소 앵커 배치(anchorFloatingPanel), 커서 기준 팝업 배치
|
||||
// (placeNearCursor), 내용 높이에 맞춘 창 줄이기(fitAnchoredPanel).
|
||||
//
|
||||
// 순수 함수만 둔다 — Electron/Node 의존 금지.
|
||||
|
||||
// ============================================================
|
||||
// 좌표 공간
|
||||
// ============================================================
|
||||
|
||||
export interface UiRect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface UiPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface UiSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 타입 전용 표지 — 런타임 값은 없다. */
|
||||
declare const COORDINATE_SPACE: unique symbol
|
||||
|
||||
/**
|
||||
* 물리 픽셀 사각형 (UIA BoundingRectangle, GetWindowRect 등 DPI-aware 네이티브 API 가 주는 값).
|
||||
*
|
||||
* 표지는 선택적이다 — 네이티브 경계(UIA 페이로드)에서 들어온 평범한 UiRect 도 받아들이되,
|
||||
* DipRect 는 받지 않는다(표지 값이 'dip' 이라 충돌한다).
|
||||
*/
|
||||
export type PhysicalRect = UiRect & { readonly [COORDINATE_SPACE]?: 'physical' }
|
||||
|
||||
/** Electron DIP 사각형 — 반드시 ScreenGeometryPort(또는 asDipRect)로만 만든다. */
|
||||
export type DipRect = UiRect & { readonly [COORDINATE_SPACE]: 'dip' }
|
||||
|
||||
/** Electron DIP 점. */
|
||||
export type DipPoint = UiPoint & { readonly [COORDINATE_SPACE]: 'dip' }
|
||||
|
||||
/** 이미 DIP 인 값(Electron screen API 결과)에 표지를 붙인다. 어댑터 경계에서만 쓴다. */
|
||||
export function asDipRect(rect: UiRect): DipRect {
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } as DipRect
|
||||
}
|
||||
|
||||
/** 이미 DIP 인 점(Electron screen API 결과)에 표지를 붙인다. 어댑터 경계에서만 쓴다. */
|
||||
export function asDipPoint(point: UiPoint): DipPoint {
|
||||
return { x: point.x, y: point.y } as DipPoint
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 기하 포트 — 좌표 공간 변환과 작업 영역 조회를 한 곳에 모은다 (DIP).
|
||||
*
|
||||
* Electron 어댑터는 win32 에서 screen.screenToDipRect 를, 그 밖에서는 항등 변환을 쓴다.
|
||||
* 배치 정책은 이 포트만 보고 DIP 로만 계산한다.
|
||||
*/
|
||||
export interface ScreenGeometryPort {
|
||||
/** 물리 픽셀 사각형 → DIP (배율이 다른 모니터에서도 그 모니터 기준으로 변환한다) */
|
||||
toDip(rect: PhysicalRect): DipRect
|
||||
/** 이 점에서 가장 가까운 모니터의 작업 영역 (DIP) */
|
||||
workAreaNear(point: DipPoint): DipRect
|
||||
/** 마우스 커서 위치 (DIP) */
|
||||
cursor(): DipPoint
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 캐럿/요소 앵커 배치
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지.
|
||||
*
|
||||
* 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는
|
||||
* elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그
|
||||
* 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을
|
||||
* 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다.
|
||||
*/
|
||||
export type AnchorKind = 'caret' | 'element' | null
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (max < min) return min
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
function fitsInWorkArea(x: number, y: number, size: UiSize, workArea: UiRect): boolean {
|
||||
return (
|
||||
x >= workArea.x &&
|
||||
y >= workArea.y &&
|
||||
x + size.width <= workArea.x + workArea.width &&
|
||||
y + size.height <= workArea.y + workArea.height
|
||||
)
|
||||
}
|
||||
|
||||
function isUsableRect(rect: UiRect | null): rect is UiRect {
|
||||
return rect !== null && rect.width >= 0 && rect.height >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 오버레이 좌표 계산.
|
||||
*
|
||||
* 모든 인자는 같은 좌표 공간이어야 한다 — 공간을 타입으로 강제하려면
|
||||
* anchorFloatingPanelInDips 를 쓴다.
|
||||
*
|
||||
* 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과
|
||||
* 같은 관례다. 아래 공간이 없으면 위로 뒤집는다.
|
||||
* 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 —
|
||||
* 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다.
|
||||
* 마지막으로 항상 작업영역 안으로 클램프한다.
|
||||
*/
|
||||
export function anchorFloatingPanel(
|
||||
anchor: UiRect | null,
|
||||
anchorKind: AnchorKind,
|
||||
cursor: UiPoint,
|
||||
size: UiSize,
|
||||
workArea: UiRect,
|
||||
gap = 6
|
||||
): UiPoint {
|
||||
if (anchorKind === 'element' && isUsableRect(anchor)) {
|
||||
return anchorOutsideElement(anchor, size, workArea, gap)
|
||||
}
|
||||
|
||||
const rect: UiRect = isUsableRect(anchor) ? anchor : { x: cursor.x, y: cursor.y, width: 0, height: 0 }
|
||||
|
||||
const anchorBottom = rect.y + Math.max(rect.height, 0)
|
||||
const desiredY = anchorBottom + gap
|
||||
const fitsBelow = desiredY + size.height <= workArea.y + workArea.height
|
||||
const above = rect.y - gap - size.height
|
||||
const y = fitsBelow ? desiredY : above >= workArea.y ? above : desiredY
|
||||
|
||||
return {
|
||||
x: clamp(rect.x, workArea.x, workArea.x + workArea.width - size.width),
|
||||
y: clamp(y, workArea.y, workArea.y + workArea.height - size.height)
|
||||
}
|
||||
}
|
||||
|
||||
/** anchorFloatingPanel 의 DIP 전용 형태 — 물리 픽셀 값이 섞이면 컴파일되지 않는다. */
|
||||
export function anchorFloatingPanelInDips(
|
||||
anchor: DipRect | null,
|
||||
anchorKind: AnchorKind,
|
||||
cursor: DipPoint,
|
||||
size: UiSize,
|
||||
workArea: DipRect,
|
||||
gap = 6
|
||||
): DipPoint {
|
||||
return asDipPoint(anchorFloatingPanel(anchor, anchorKind, cursor, size, workArea, gap))
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 앵커를 요소 "바깥" 에 배치한다.
|
||||
*
|
||||
* 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을
|
||||
* 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다
|
||||
* (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에
|
||||
* 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다.
|
||||
*/
|
||||
function anchorOutsideElement(element: UiRect, size: UiSize, workArea: UiRect, gap: number): UiPoint {
|
||||
const candidates: UiPoint[] = [
|
||||
{ x: element.x, y: element.y + element.height + gap }, // 아래
|
||||
{ x: element.x, y: element.y - gap - size.height }, // 위
|
||||
{ x: element.x + element.width + gap, y: element.y }, // 오른쪽
|
||||
{ x: element.x - gap - size.width, y: element.y } // 왼쪽
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
x: element.x + element.width - size.width - gap,
|
||||
y: element.y + element.height - size.height - gap
|
||||
}
|
||||
|
||||
return {
|
||||
x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width),
|
||||
y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 앵커 기준으로 배치한 패널 자리. 창 높이는 내용에 따라 줄어들 수 있다(fitAnchoredPanel).
|
||||
*
|
||||
* `grow` 는 내용이 늘 때 창이 자라는 방향이다 — 앵커 위로 뒤집혀 배치됐으면 아래쪽 모서리를
|
||||
* 앵커 곁에 고정하고 위로 자란다.
|
||||
*/
|
||||
export interface AnchoredPanelPlacement {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
/** 배치 계산에 쓴 최대 높이 */
|
||||
maxHeight: number
|
||||
grow: 'down' | 'up'
|
||||
}
|
||||
|
||||
/**
|
||||
* 물리 픽셀 앵커로 오버레이 자리를 잡는다 — 변환은 포트로 한 번만 한다.
|
||||
*
|
||||
* 모니터 선택도 변환된 DIP 점으로 한다 (물리 좌표로 고르면 배율이 다른 모니터가 여럿일 때
|
||||
* 엉뚱한 모니터의 작업 영역을 고른다).
|
||||
*/
|
||||
export function placeAnchoredPanel(
|
||||
port: ScreenGeometryPort,
|
||||
anchor: PhysicalRect | null,
|
||||
anchorKind: AnchorKind,
|
||||
size: UiSize,
|
||||
gap = 6
|
||||
): AnchoredPanelPlacement {
|
||||
const cursor = port.cursor()
|
||||
const dipAnchor = isUsableRect(anchor) ? port.toDip(anchor) : null
|
||||
const anchorPoint = dipAnchor
|
||||
? asDipPoint({ x: dipAnchor.x, y: dipAnchor.y + dipAnchor.height })
|
||||
: cursor
|
||||
const workArea = port.workAreaNear(anchorPoint)
|
||||
const position = anchorFloatingPanelInDips(dipAnchor, anchorKind, cursor, size, workArea, gap)
|
||||
const referenceTop = dipAnchor ? dipAnchor.y : cursor.y
|
||||
return {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: size.width,
|
||||
maxHeight: size.height,
|
||||
grow: position.y + size.height <= referenceTop ? 'up' : 'down'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치된 패널 창을 실제 내용 높이로 줄인다.
|
||||
*
|
||||
* 투명 창이라도 창 영역은 클릭을 삼킨다 — 보이지 않는 여백이 캐럿 아래 줄의 클릭을 막지
|
||||
* 않도록 창을 그려진 패널만큼만 둔다. 위로 뒤집힌 배치는 아래 모서리(앵커 쪽)를 고정한다.
|
||||
*/
|
||||
export function fitAnchoredPanel(placement: AnchoredPanelPlacement, contentHeight: number): UiRect {
|
||||
const height = clamp(Math.round(contentHeight), 1, placement.maxHeight)
|
||||
const y = placement.grow === 'up' ? placement.y + placement.maxHeight - height : placement.y
|
||||
return { x: placement.x, y, width: placement.width, height }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 커서 기준 팝업 배치
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 커서 기준 팝업 배치 (녹음 팁 · 결과 · 기록 · 명령 팝업 공통).
|
||||
*
|
||||
* 커서 위 `gap` 에 가로 가운데로 두고, 위 공간이 없으면 커서 아래로 뒤집는다.
|
||||
* 그다음 작업 영역 안으로 네 방향 모두 클램프한다 (아래로 뒤집힌 큰 팝업이 화면 밖으로
|
||||
* 잘리지 않게). 음수 좌표(주 모니터 왼쪽/위쪽 모니터)도 그대로 다룬다.
|
||||
*/
|
||||
export function placeNearCursor(cursor: UiPoint, size: UiSize, workArea: UiRect, gap = 20): UiPoint {
|
||||
const x = clamp(
|
||||
cursor.x - Math.round(size.width / 2),
|
||||
workArea.x,
|
||||
workArea.x + workArea.width - size.width
|
||||
)
|
||||
let y = cursor.y - size.height - gap
|
||||
if (y < workArea.y) y = cursor.y + gap
|
||||
if (y + size.height > workArea.y + workArea.height) {
|
||||
y = Math.max(workArea.y, workArea.y + workArea.height - size.height)
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
|
@ -162,6 +162,16 @@ function suffixPrefixOverlap(prefix: string, candidate: string): number {
|
|||
return 0
|
||||
}
|
||||
|
||||
/** 로컬 기억 후보 하나 — 삽입할 텍스트와, 접두에 붙여 써야 하는지(단어 중간에서 이어짐). */
|
||||
export interface LocalSuggestionCandidate {
|
||||
text: string
|
||||
/**
|
||||
* true 면 접두의 단어 중간에서 이어지는 나머지다(예: "Thank yo" + "u for the update") —
|
||||
* 수락할 때 구분 공백을 넣지 않는다. joinSuggestion 의 joint 'attach' 에 해당한다.
|
||||
*/
|
||||
attachToPrefix: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 기억만으로 삽입 가능한 다음 문자열을 만든다.
|
||||
*
|
||||
|
|
@ -175,6 +185,21 @@ export function buildLocalSuggestionCandidates(
|
|||
limit = 3,
|
||||
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
|
||||
): string[] {
|
||||
return buildLocalSuggestionCandidateEntries(prefix, hints, limit, maxChars).map((entry) => entry.text)
|
||||
}
|
||||
|
||||
/**
|
||||
* buildLocalSuggestionCandidates 와 같은 후보를, 접두와의 이음새 정보와 함께 돌려준다.
|
||||
*
|
||||
* 문구 겹침(접두 꼬리 = 문구 머리)으로 잘라 낸 나머지가 공백으로 시작하지 않으면 단어
|
||||
* 중간에서 이어지는 것이다 — 정규화(trim)가 그 정보를 지우기 전에 여기서 기록한다.
|
||||
*/
|
||||
export function buildLocalSuggestionCandidateEntries(
|
||||
prefix: string,
|
||||
hints: LocalSuggestionHints,
|
||||
limit = 3,
|
||||
maxChars = SUGGESTION_MAX_OUTPUT_CHARS
|
||||
): LocalSuggestionCandidate[] {
|
||||
const safeLimit = Math.max(0, Math.floor(limit))
|
||||
const safeMaxChars = Math.max(0, Math.floor(maxChars))
|
||||
if (safeLimit === 0 || safeMaxChars < 2) return []
|
||||
|
|
@ -182,13 +207,13 @@ export function buildLocalSuggestionCandidates(
|
|||
const normalizedPrefix = normalizeRequestPrefix(prefix)
|
||||
const normalizedPrefixLower = normalizedPrefix.toLowerCase()
|
||||
const acceptsWholePhrase = endsSentence(normalizedPrefix)
|
||||
const out: string[] = []
|
||||
const out: LocalSuggestionCandidate[] = []
|
||||
|
||||
const append = (candidate: string | null): void => {
|
||||
const append = (candidate: string | null, attachToPrefix = false): void => {
|
||||
if (candidate === null || candidate.length === 0) return
|
||||
if (candidate.toLowerCase() === normalizedPrefixLower) return
|
||||
if (out.some((existing) => existing.toLowerCase() === candidate.toLowerCase())) return
|
||||
out.push(candidate)
|
||||
if (out.some((existing) => existing.text.toLowerCase() === candidate.toLowerCase())) return
|
||||
out.push({ text: candidate, attachToPrefix })
|
||||
}
|
||||
|
||||
for (const raw of hints.continuationHints) {
|
||||
|
|
@ -206,7 +231,8 @@ export function buildLocalSuggestionCandidates(
|
|||
if (candidate === null) continue
|
||||
const overlap = suffixPrefixOverlap(normalizedPrefix, candidate)
|
||||
if (overlap >= 2) {
|
||||
append(normalizeSuggestionText(candidate.slice(overlap), safeMaxChars))
|
||||
const remainder = candidate.slice(overlap)
|
||||
append(normalizeSuggestionText(remainder, safeMaxChars), !/^\s/u.test(remainder))
|
||||
} else if (acceptsWholePhrase) {
|
||||
append(candidate)
|
||||
}
|
||||
|
|
@ -215,3 +241,53 @@ export function buildLocalSuggestionCandidates(
|
|||
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 수락 시 이음새
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 후보가 접두와 어떻게 이어지는가.
|
||||
* - separate: 새 단어/문장이다 — 띄어 쓰는 문자 체계면 공백 하나로 떼어 쓴다.
|
||||
* - attach: 접두의 단어 중간에서 이어지는 나머지다 — 그대로 붙인다.
|
||||
*/
|
||||
export type SuggestionJoint = 'separate' | 'attach'
|
||||
|
||||
/** 이 문자 뒤에는 떼어 쓰지 않는다 (여는 괄호·따옴표, 경로·멘션·해시 기호). */
|
||||
const NO_SPACE_AFTER = /[([{<“‘«‹「『【〔〈《([{/\\@#_-]/u
|
||||
/** 이 문자로 시작하는 후보는 앞 글자에 붙는다 (닫는 괄호·구두점). */
|
||||
const NO_SPACE_BEFORE = /[.,;:!?)\]}>…%’”»›」』】〕〉》、。,.!?:;)]}]/u
|
||||
/** 단어 사이를 띄어 쓰지 않는 문자 체계 + CJK 구두점·전각 기호. */
|
||||
const UNSPACED_JOIN_SCRIPT =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\u3000-\u303f\uff00-\uffef]/u
|
||||
|
||||
function lastCodePoint(text: string): string {
|
||||
const chars = Array.from(text)
|
||||
return chars[chars.length - 1] ?? ''
|
||||
}
|
||||
|
||||
function firstCodePoint(text: string): string {
|
||||
return Array.from(text)[0] ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 수락한 후보를 커서 위치에 넣을 문자열로 만든다 (필요하면 앞에 공백 하나).
|
||||
*
|
||||
* 후보는 정제 과정에서 앞 공백이 모두 사라진다. 사용자는 대개 마지막 글자(단어 끝이나
|
||||
* 마침표) 바로 뒤에서 멈추므로, 그대로 붙이면 "report.Let me…", "회의에서논의한…" 처럼
|
||||
* 들러붙는다. 다음 경우에만 공백을 넣는다:
|
||||
* - 커서 앞 원문(rawPrefix)이 비어 있지 않고 공백으로 끝나지 않는다.
|
||||
* - 후보가 접두의 단어 중간에서 이어지는 나머지(joint 'attach')가 아니다.
|
||||
* - 앞 글자가 여는 괄호/따옴표·경로 기호가 아니고, 후보가 닫는 구두점으로 시작하지 않는다.
|
||||
* - 이음새 양쪽 어느 쪽도 띄어 쓰지 않는 문자 체계(중·일·태 등, CJK 구두점)가 아니다.
|
||||
*/
|
||||
export function joinSuggestion(rawPrefix: string, candidate: string, joint: SuggestionJoint = 'separate'): string {
|
||||
if (!candidate || joint === 'attach') return candidate
|
||||
const before = lastCodePoint(rawPrefix)
|
||||
const after = firstCodePoint(candidate)
|
||||
if (!before || !after) return candidate
|
||||
if (/\s/u.test(before) || /\s/u.test(after)) return candidate
|
||||
if (NO_SPACE_AFTER.test(before) || NO_SPACE_BEFORE.test(after)) return candidate
|
||||
if (UNSPACED_JOIN_SCRIPT.test(before) || UNSPACED_JOIN_SCRIPT.test(after)) return candidate
|
||||
return ` ${candidate}`
|
||||
}
|
||||
|
|
|
|||
65
packages/core/src/text-segmentation.ts
Normal file
65
packages/core/src/text-segmentation.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// packages/core — 단어 분할 정본 (학습용 단어 수 · 사전 기반 분할기).
|
||||
//
|
||||
// 공백 분리만으로는 띄어쓰기 없이 쓰는 문자 체계(중국어·일본어·태국어 등)의 문장 전체가
|
||||
// 한 "단어" 가 된다. 학습 필터("2단어 이상")가 그 언어의 문장을 전부 버리지 않도록
|
||||
// 런타임의 Intl.Segmenter 로 나누고, 없으면(Hermes 등) 글자 수로 어림한다.
|
||||
//
|
||||
// 순수 함수만 둔다 — Electron/Node 의존 금지. 다른 core 모듈을 import 하지 않는다(순환 방지).
|
||||
|
||||
let wordSegmenter: Intl.Segmenter | null | undefined
|
||||
|
||||
/** 런타임이 지원할 때만 단어 분할기를 만든다 (Hermes 등에는 없을 수 있다). */
|
||||
export function getWordSegmenter(): Intl.Segmenter | null {
|
||||
if (wordSegmenter !== undefined) return wordSegmenter
|
||||
wordSegmenter =
|
||||
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
: null
|
||||
return wordSegmenter
|
||||
}
|
||||
|
||||
/** 단어 사이를 띄어 쓰지 않는 문자 체계 (한자 · 가나 · 태국/라오/크메르/미얀마 문자). */
|
||||
const UNSPACED_WORD_SCRIPT =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u
|
||||
const UNSPACED_WORD_SCRIPT_GLOBAL = new RegExp(UNSPACED_WORD_SCRIPT.source, 'gu')
|
||||
const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u
|
||||
|
||||
/** 분할기가 없을 때 띄어쓰기 없는 문자 몇 자를 한 단어로 어림할지 (한·중·일 평균 단어 길이) */
|
||||
const UNSPACED_CHARS_PER_WORD = 2
|
||||
|
||||
function countSpacedWords(text: string): number {
|
||||
let count = 0
|
||||
for (const token of text.split(/\s+/u)) {
|
||||
if (LETTER_OR_DIGIT.test(token)) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* 학습 필터용 단어 수.
|
||||
*
|
||||
* 띄어쓰기 없는 문자 체계가 없으면 공백 분리 결과(countWords 와 같다)를 그대로 쓴다 —
|
||||
* 한국어·라틴 문자의 기존 동작은 바뀌지 않는다. 있으면 Intl.Segmenter 의 단어(isWordLike)
|
||||
* 수를 쓰고, 분할기가 없으면 그 문자들을 UNSPACED_CHARS_PER_WORD 자당 한 단어로 어림한다.
|
||||
*/
|
||||
export function countLexicalWords(text: string): number {
|
||||
if (!text) return 0
|
||||
if (!UNSPACED_WORD_SCRIPT.test(text)) return countSpacedWords(text)
|
||||
|
||||
const segmenter = getWordSegmenter()
|
||||
if (segmenter) {
|
||||
let count = 0
|
||||
for (const part of segmenter.segment(text)) {
|
||||
if (part.isWordLike) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (const token of text.split(/\s+/u)) {
|
||||
if (!LETTER_OR_DIGIT.test(token)) continue
|
||||
const unspaced = token.match(UNSPACED_WORD_SCRIPT_GLOBAL)?.length ?? 0
|
||||
count += unspaced > 0 ? Math.max(1, Math.floor(unspaced / UNSPACED_CHARS_PER_WORD)) : 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue