Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 117 additions & 9 deletions apps/client-chrome/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ArrowLeft, CalendarDays, Check, Cloud, Copy, Link2, Monitor, RefreshCw, Search, Send, Settings, Sparkles, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getActiveTab, subscribeToActiveTab } from './chromeApi'
import { getBrowserTimeZone, getLocalDate, normalizeBrainEndpointUrl, runDailySummary } from './brainCapture'
import type { DailySummaryResult } from './brainCapture'
import { getBrowserTimeZone, getDailySummaryVariant, getLocalDate, normalizeBrainEndpointUrl, runDailySummary } from './brainCapture'
import type { DailySummaryResult, DailySummarySource, EvidenceSupport, SummaryLevel } from './brainCapture'
import { DEFAULT_BRAIN_SERVER_URL } from './config'
import { checkContextServerConnection, createUrlMemoryCapture, deleteRemoteCapture, listRemoteCaptures, normalizeEndpointUrl, postCapture } from './contextCapture'
import type { RemoteCapture } from './contextCapture'
import { filterLinkedThoughtsForUrl, LinkedThoughtsLoader } from './linkedThoughts'
import { createI18n, resolveLocale } from './i18n'
import type { LocalePreference } from './i18n'
import type { LocalePreference, MessageKey } from './i18n'
import { loadLocalePreference, loadStoredSettings, saveAppData, saveLocalePreference, saveStoredSettings, subscribeToLocalePreferenceChanges, subscribeToStoredSettingsChanges } from './storage'
import { copyTextToClipboard, filterThoughtsForCopy, formatThoughtsForClipboard, getThoughtCopyRange } from './thoughtExport'
import type { AppData, CaptureOutboxItem, ChromeSurface, DeploymentMode, SavedTab, UrlMemory } from './types'
Expand All @@ -29,6 +29,87 @@ export function canCaptureOnUrl(url: string | undefined) {
}
}

const SUMMARY_LEVELS: SummaryLevel[] = ['quick', 'standard', 'deep']
const SUMMARY_LEVEL_KEYS: Record<SummaryLevel, MessageKey> = {
quick: 'summary.level.quick',
standard: 'summary.level.standard',
deep: 'summary.level.deep',
}
const SUPPORT_KEYS: Record<EvidenceSupport, MessageKey> = {
direct: 'summary.support.direct',
partial: 'summary.support.partial',
unverified: 'summary.support.unverified',
conflict: 'summary.support.conflict',
}

type SummaryResultViewProps = {
result: DailySummaryResult
level: SummaryLevel
onLevelChange: (level: SummaryLevel) => void
onOpenSource: (source: DailySummarySource) => void
formatDate: ReturnType<typeof createI18n>['formatDate']
t: ReturnType<typeof createI18n>['t']
}

function SummaryResultView({ result, level, onLevelChange, onOpenSource, formatDate, t }: SummaryResultViewProps) {
const sources = result.sources ?? []
const sourcesById = new Map(sources.map((source) => [source.recordId, source]))

return (
<section className={`summary-result ${result.recordCount === 0 ? 'is-empty' : ''}`} aria-live="polite">
<div className="summary-result-meta"><span>{result.recordCount === 0 ? t('summary.noRecords') : t('summary.recordCount', { count: result.recordCount })}</span><span>{result.date} · {result.timezone}</span></div>
{result.variants ? (
<div className="summary-levels" role="tablist" aria-label={t('summary.levelLabel')}>
{SUMMARY_LEVELS.map((option) => (
<button
className={level === option ? 'is-selected' : ''}
key={option}
type="button"
role="tab"
aria-selected={level === option}
onClick={() => onLevelChange(option)}
>
{t(SUMMARY_LEVEL_KEYS[option])}
</button>
))}
</div>
) : null}
<p className="summary-text">{getDailySummaryVariant(result, level)}</p>
{result.keyPoints.length > 0 ? <div className="summary-points"><h2>{t('summary.keyPoints')}</h2><ul>{result.keyPoints.map((point) => <li key={point}>{point}</li>)}</ul></div> : <p className="summary-empty-help">{t('summary.emptyHelp')}</p>}
{result.claims && result.claims.length > 0 ? (
<div className="summary-evidence">
<div className="summary-evidence-heading"><h2>{t('summary.evidenceTitle')}</h2><span>{t('summary.evidenceHelp')}</span></div>
<div className="summary-claims">
{result.claims.map((claim) => (
<article className={`summary-claim support-${claim.support}`} key={claim.id}>
<div className="summary-claim-status">{t(SUPPORT_KEYS[claim.support])}</div>
<p>{claim.text}</p>
{claim.sourceIds.length > 0 ? <div className="summary-claim-sources">{claim.sourceIds.map((sourceId) => {
const source = sourcesById.get(sourceId)
return source ? <button className="summary-source-chip" key={sourceId} type="button" title={source.preview} onClick={() => onOpenSource(source)}>{source.title || source.preview}</button> : null
})}</div> : null}
</article>
))}
</div>
</div>
) : null}
{sources.length > 0 ? (
<div className="summary-sources">
<h2>{t('summary.sourcesTitle', { count: sources.length })}</h2>
<div className="summary-source-list">
{sources.map((source) => (
<button className="summary-source" key={source.recordId} type="button" title={source.preview} onClick={() => onOpenSource(source)}>
<strong>{source.title || source.client} · {formatDate(new Date(source.recordedAt), { dateStyle: 'medium', timeStyle: 'short' })}</strong>
<span>{source.preview}</span>
</button>
))}
</div>
</div>
) : null}
</section>
)
}

export function App({ surface }: { surface: ChromeSurface }) {
const [localePreference, setLocalePreference] = useState<LocalePreference>('auto')
const locale = resolveLocale(localePreference)
Expand All @@ -51,6 +132,7 @@ export function App({ surface }: { surface: ChromeSurface }) {
const [connectionChecking, setConnectionChecking] = useState(false)
const [thoughts, setThoughts] = useState<RemoteCapture[]>([])
const [thoughtQuery, setThoughtQuery] = useState('')
const [thoughtIdFilter, setThoughtIdFilter] = useState('')
const [thoughtUrlFilter, setThoughtUrlFilter] = useState('')
const [thoughtStatus, setThoughtStatus] = useState('')
const [copyStart, setCopyStart] = useState('')
Expand All @@ -59,6 +141,7 @@ export function App({ surface }: { surface: ChromeSurface }) {
const [linkedThoughts, setLinkedThoughts] = useState<RemoteCapture[]>([])
const [summaryDate, setSummaryDate] = useState(() => getLocalDate())
const [summaryResult, setSummaryResult] = useState<DailySummaryResult | null>(null)
const [summaryLevel, setSummaryLevel] = useState<SummaryLevel>('standard')
const [summaryStatus, setSummaryStatus] = useState('')
const [summaryLoading, setSummaryLoading] = useState(false)
const [settingsChangedElsewhere, setSettingsChangedElsewhere] = useState(false)
Expand Down Expand Up @@ -485,13 +568,32 @@ export function App({ surface }: { surface: ChromeSurface }) {
}, [activeTab?.url, linkedThoughtsLoader, loadLinkedThoughts, t])

const openThoughts = () => {
setThoughtIdFilter('')
setThoughtUrlFilter('')
setThoughtQuery('')
setScreen('thoughts')
void loadThoughts()
}

const openSourceRecord = (source: DailySummarySource) => {
if (data?.sync.endpointUrl && apiToken) {
setThoughtIdFilter(source.recordId)
setThoughtQuery('')
setThoughtUrlFilter('')
setScreen('thoughts')
void loadThoughts()
return
}
if (source.url) {
void openThoughtLink(source.url)
return
}
setSummaryStatus(t('summary.sourceConnectionRequired'))
}

const openSummary = () => {
setSummaryDate(getLocalDate())
setSummaryLevel('standard')
setSummaryStatus('')
setScreen('summary')
}
Expand All @@ -514,8 +616,9 @@ export function App({ surface }: { surface: ChromeSurface }) {
}
}

const openLinkedThoughts = () => {
const openLinkedThoughts = () => {
if (!activeTab?.url) return
setThoughtIdFilter('')
setThoughtQuery('')
setThoughtUrlFilter(activeTab.url)
setScreen('thoughts')
Expand Down Expand Up @@ -602,6 +705,7 @@ export function App({ surface }: { surface: ChromeSurface }) {
const canCapture = !activeTab || canCaptureOnUrl(activeTab.url)
const visibleThoughts = thoughts.filter((thought) =>
thought.data.content.toLocaleLowerCase().includes(thoughtQuery.trim().toLocaleLowerCase()) &&
(!thoughtIdFilter || thought.id === thoughtIdFilter) &&
(!thoughtUrlFilter || thought.data.context?.browser?.url === thoughtUrlFilter)
)

Expand Down Expand Up @@ -725,11 +829,14 @@ export function App({ surface }: { surface: ChromeSurface }) {
</button>
{summaryStatus ? <p className="summary-error" role="alert">{summaryStatus}</p> : null}
{summaryResult ? (
<section className={`summary-result ${summaryResult.recordCount === 0 ? 'is-empty' : ''}`} aria-live="polite">
<div className="summary-result-meta"><span>{summaryResult.recordCount === 0 ? t('summary.noRecords') : t('summary.recordCount', { count: summaryResult.recordCount })}</span><span>{summaryResult.date} · {summaryResult.timezone}</span></div>
<p className="summary-text">{summaryResult.summary}</p>
{summaryResult.keyPoints.length > 0 ? <div className="summary-points"><h2>{t('summary.keyPoints')}</h2><ul>{summaryResult.keyPoints.map((point) => <li key={point}>{point}</li>)}</ul></div> : <p className="summary-empty-help">{t('summary.emptyHelp')}</p>}
</section>
<SummaryResultView
result={summaryResult}
level={summaryLevel}
onLevelChange={setSummaryLevel}
onOpenSource={openSourceRecord}
formatDate={formatDate}
t={t}
/>
) : null}
</section>
) : screen === 'thoughts' ? (
Expand All @@ -744,6 +851,7 @@ export function App({ surface }: { surface: ChromeSurface }) {
<div><Search size={17} aria-hidden="true" /><input value={thoughtQuery} onChange={(event) => setThoughtQuery(event.target.value)} placeholder={t('thoughts.searchPlaceholder')} /></div>
</label>
{thoughtUrlFilter ? <div className="link-filter"><Link2 size={14} /><span>{t('thoughts.filteredByLink')}</span><button type="button" onClick={() => setThoughtUrlFilter('')}>{t('thoughts.showAll')}</button></div> : null}
{thoughtIdFilter ? <div className="link-filter"><Link2 size={14} /><span>{t('thoughts.filteredByRecord')}</span><button type="button" onClick={() => setThoughtIdFilter('')}>{t('thoughts.showAll')}</button></div> : null}
<section className="copy-thoughts" aria-labelledby="copy-thoughts-title">
<div className="copy-thoughts-heading"><div><p className="eyebrow">{t('thoughts.externalUse')}</p><h2 id="copy-thoughts-title">{t('thoughts.copyTitle')}</h2></div><Copy size={18} aria-hidden="true" /></div>
<p>{t('thoughts.copyDescription')}</p>
Expand Down
58 changes: 56 additions & 2 deletions apps/client-chrome/src/brainCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,33 @@ export type DailySummaryInput = {
timezone: string
}

export type SummaryLevel = 'quick' | 'standard' | 'deep'
export type EvidenceSupport = 'direct' | 'partial' | 'unverified' | 'conflict'
export type DailySummaryVariants = Record<SummaryLevel, string>
export type DailySummaryClaim = {
id: string
text: string
sourceIds: string[]
support: EvidenceSupport
}
export type DailySummarySource = {
recordId: string
preview: string
recordedAt: string
client: string
title?: string
url?: string
}

export type DailySummaryResult = {
date: string
timezone: string
recordCount: number
summary: string
keyPoints: string[]
variants?: DailySummaryVariants
claims?: DailySummaryClaim[]
sources?: DailySummarySource[]
}

export class BrainRequestError extends Error {}
Expand All @@ -38,12 +59,45 @@ function headers(apiToken: string) {
function isDailySummaryResult(value: unknown): value is DailySummaryResult {
if (!value || typeof value !== 'object') return false
const result = value as Partial<DailySummaryResult>
return typeof result.date === 'string' &&
if (!(typeof result.date === 'string' &&
typeof result.timezone === 'string' &&
typeof result.recordCount === 'number' &&
typeof result.summary === 'string' &&
Array.isArray(result.keyPoints) &&
result.keyPoints.every((point) => typeof point === 'string')
result.keyPoints.every((point) => typeof point === 'string'))) return false

if (result.variants !== undefined) {
const variants = result.variants
if (!variants || typeof variants !== 'object' || Array.isArray(variants)) return false
if (!['quick', 'standard', 'deep'].every((level) => typeof variants[level as SummaryLevel] === 'string')) return false
}

if (result.claims !== undefined) {
if (!Array.isArray(result.claims) || !result.claims.every((claim) => {
if (!claim || typeof claim !== 'object') return false
const item = claim as Partial<DailySummaryClaim>
return typeof item.id === 'string' && typeof item.text === 'string' && Array.isArray(item.sourceIds) &&
item.sourceIds.every((sourceId) => typeof sourceId === 'string') &&
typeof item.support === 'string' && ['direct', 'partial', 'unverified', 'conflict'].includes(item.support)
})) return false
}

if (result.sources !== undefined) {
if (!Array.isArray(result.sources) || !result.sources.every((source) => {
if (!source || typeof source !== 'object') return false
const item = source as Partial<DailySummarySource>
return typeof item.recordId === 'string' && typeof item.preview === 'string' &&
typeof item.recordedAt === 'string' && typeof item.client === 'string' &&
(item.title === undefined || typeof item.title === 'string') &&
(item.url === undefined || typeof item.url === 'string')
})) return false
}

return true
}

export function getDailySummaryVariant(result: DailySummaryResult, level: SummaryLevel): string {
return result.variants?.[level] ?? result.summary
}

function errorMessage(status: number, body: unknown) {
Expand Down
26 changes: 26 additions & 0 deletions apps/client-chrome/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,24 @@ const messages = {
'summary.recordCount': 'Context 기록 {count}개',
'summary.keyPoints': '핵심 포인트',
'summary.emptyHelp': '먼저 생각을 남기면 이 날짜의 요약이 여기에 나타납니다.',
'summary.levelLabel': '답변 깊이',
'summary.level.quick': '빠른 답변',
'summary.level.standard': '일반 답변',
'summary.level.deep': '자세한 답변',
'summary.evidenceTitle': '주장과 근거',
'summary.evidenceHelp': '근거 상태를 쉽게 확인할 수 있습니다.',
'summary.support.direct': '직접 확인됨',
'summary.support.partial': '부분 확인',
'summary.support.unverified': '미확인',
'summary.support.conflict': '충돌 있음',
'summary.sourcesTitle': '근거 기록 {count}개',
'summary.sourceConnectionRequired': '원문 기록을 열려면 Context Server 연결이 필요합니다.',
'thoughts.title': '생각 전체 조회',
'thoughts.refresh': '생각 새로고침',
'thoughts.search': '생각 검색',
'thoughts.searchPlaceholder': '기억나는 단어를 입력하세요',
'thoughts.filteredByLink': '현재 링크의 기록만 표시 중',
'thoughts.filteredByRecord': '선택한 기록만 표시 중',
'thoughts.showAll': '전체 보기',
'thoughts.externalUse': 'External use',
'thoughts.copyTitle': '생각 복사',
Expand Down Expand Up @@ -287,11 +300,24 @@ const messages = {
'summary.recordCount': '{count} Context record(s)',
'summary.keyPoints': 'Key points',
'summary.emptyHelp': 'Leave a thought first and its summary for this date will appear here.',
'summary.levelLabel': 'Answer depth',
'summary.level.quick': 'Quick',
'summary.level.standard': 'Standard',
'summary.level.deep': 'Deep',
'summary.evidenceTitle': 'Claims and evidence',
'summary.evidenceHelp': 'See how each claim is supported.',
'summary.support.direct': 'Directly supported',
'summary.support.partial': 'Partially supported',
'summary.support.unverified': 'Unverified',
'summary.support.conflict': 'Conflicting sources',
'summary.sourcesTitle': '{count} source record(s)',
'summary.sourceConnectionRequired': 'Connect Context Server to open the original record.',
'thoughts.title': 'All thoughts',
'thoughts.refresh': 'Refresh thoughts',
'thoughts.search': 'Search thoughts',
'thoughts.searchPlaceholder': 'Enter a word you remember',
'thoughts.filteredByLink': 'Showing only records for the current link',
'thoughts.filteredByRecord': 'Showing the selected record only',
'thoughts.showAll': 'Show all',
'thoughts.externalUse': 'External use',
'thoughts.copyTitle': 'Copy thoughts',
Expand Down
Loading
Loading