ltcai 0.1.30 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/static/graph.html CHANGED
@@ -73,12 +73,17 @@
73
73
  </div>
74
74
 
75
75
  <div class="section">
76
- <div class="section-label">Relationship legend</div>
76
+ <div class="section-label" id="local-source-label">지식 소스</div>
77
+ <div id="local-source-panel" class="local-source-panel"></div>
78
+ </div>
79
+
80
+ <div class="section">
81
+ <div class="section-label" id="edge-label">Relationship legend</div>
77
82
  <div id="edge-legend" class="legend-grid"></div>
78
83
  </div>
79
84
 
80
85
  <div class="section">
81
- <div class="section-label">Node types</div>
86
+ <div class="section-label" id="type-label">Node types</div>
82
87
  <div id="type-filters" class="filter-grid"></div>
83
88
  </div>
84
89
 
@@ -2947,6 +2947,226 @@ body.lattice-ref-graph {
2947
2947
  gap: 7px;
2948
2948
  }
2949
2949
 
2950
+ .local-source-panel {
2951
+ display: flex;
2952
+ flex-direction: column;
2953
+ gap: 10px;
2954
+ }
2955
+
2956
+ .local-source-notice {
2957
+ border: 1px solid rgba(13,148,136,0.20);
2958
+ border-radius: 8px;
2959
+ background: rgba(13,148,136,0.07);
2960
+ color: #21514b;
2961
+ padding: 9px 10px;
2962
+ font-size: 12px;
2963
+ line-height: 1.5;
2964
+ }
2965
+
2966
+ .local-source-input {
2967
+ display: flex;
2968
+ gap: 8px;
2969
+ align-items: center;
2970
+ }
2971
+
2972
+ .local-source-input input {
2973
+ min-width: 0;
2974
+ flex: 1;
2975
+ height: 38px;
2976
+ border-radius: 8px;
2977
+ border: 1px solid rgba(111,66,232,0.16);
2978
+ background: #fff;
2979
+ color: var(--text);
2980
+ padding: 0 10px;
2981
+ font-size: 12px;
2982
+ outline: none;
2983
+ }
2984
+
2985
+ .local-source-input input:focus {
2986
+ border-color: rgba(111,66,232,0.42);
2987
+ box-shadow: 0 0 0 3px rgba(111,66,232,0.08);
2988
+ }
2989
+
2990
+ .local-root-list,
2991
+ .local-tree-list,
2992
+ .local-source-list {
2993
+ display: flex;
2994
+ flex-direction: column;
2995
+ gap: 6px;
2996
+ max-height: 150px;
2997
+ overflow-y: auto;
2998
+ padding-right: 2px;
2999
+ }
3000
+
3001
+ .local-root-btn,
3002
+ .local-tree-row,
3003
+ .local-source-row {
3004
+ width: 100%;
3005
+ border: 1px solid rgba(111,66,232,0.13);
3006
+ border-radius: 8px;
3007
+ background: rgba(255,255,255,0.80);
3008
+ color: var(--text);
3009
+ padding: 8px 9px;
3010
+ display: grid;
3011
+ grid-template-columns: 18px minmax(0, 1fr) auto;
3012
+ gap: 8px;
3013
+ align-items: center;
3014
+ text-align: left;
3015
+ font-size: 12px;
3016
+ }
3017
+
3018
+ .local-root-btn {
3019
+ cursor: pointer;
3020
+ }
3021
+
3022
+ .local-root-btn:hover,
3023
+ .local-root-btn.active {
3024
+ border-color: rgba(111,66,232,0.34);
3025
+ background: rgba(111,66,232,0.07);
3026
+ }
3027
+
3028
+ .local-source-main,
3029
+ .local-tree-main {
3030
+ min-width: 0;
3031
+ }
3032
+
3033
+ .local-source-main strong,
3034
+ .local-tree-main strong {
3035
+ display: block;
3036
+ font-size: 12px;
3037
+ line-height: 1.25;
3038
+ overflow: hidden;
3039
+ text-overflow: ellipsis;
3040
+ white-space: nowrap;
3041
+ }
3042
+
3043
+ .local-source-main span,
3044
+ .local-tree-main span {
3045
+ display: block;
3046
+ color: var(--faint);
3047
+ font-size: 11px;
3048
+ line-height: 1.35;
3049
+ overflow: hidden;
3050
+ text-overflow: ellipsis;
3051
+ white-space: nowrap;
3052
+ }
3053
+
3054
+ .local-source-actions {
3055
+ display: grid;
3056
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3057
+ gap: 7px;
3058
+ }
3059
+
3060
+ .local-source-btn {
3061
+ min-width: 0;
3062
+ height: 36px;
3063
+ border: 1px solid rgba(111,66,232,0.18);
3064
+ border-radius: 8px;
3065
+ background: #fff;
3066
+ color: var(--text);
3067
+ cursor: pointer;
3068
+ display: inline-flex;
3069
+ align-items: center;
3070
+ justify-content: center;
3071
+ gap: 6px;
3072
+ font-size: 12px;
3073
+ font-weight: 650;
3074
+ }
3075
+
3076
+ .local-source-btn:hover {
3077
+ border-color: rgba(111,66,232,0.42);
3078
+ color: var(--accent);
3079
+ background: rgba(111,66,232,0.07);
3080
+ }
3081
+
3082
+ .local-source-btn.primary {
3083
+ grid-column: 1 / -1;
3084
+ background: #14162c;
3085
+ color: #fff;
3086
+ border-color: #14162c;
3087
+ }
3088
+
3089
+ .local-source-btn.primary:hover {
3090
+ color: #fff;
3091
+ background: #24284a;
3092
+ }
3093
+
3094
+ .local-source-btn:disabled {
3095
+ cursor: not-allowed;
3096
+ opacity: 0.55;
3097
+ transform: none;
3098
+ }
3099
+
3100
+ .local-option-row {
3101
+ display: flex;
3102
+ flex-direction: column;
3103
+ gap: 6px;
3104
+ }
3105
+
3106
+ .local-option-row label {
3107
+ display: inline-flex;
3108
+ align-items: center;
3109
+ gap: 6px;
3110
+ color: var(--muted);
3111
+ font-size: 12px;
3112
+ line-height: 1.3;
3113
+ white-space: nowrap;
3114
+ }
3115
+
3116
+ .local-option-row input {
3117
+ margin: 0;
3118
+ accent-color: var(--accent);
3119
+ }
3120
+
3121
+ .local-audit-grid {
3122
+ display: grid;
3123
+ grid-template-columns: repeat(3, minmax(0, 1fr));
3124
+ gap: 6px;
3125
+ }
3126
+
3127
+ .local-audit-stat {
3128
+ border: 1px solid rgba(111,66,232,0.13);
3129
+ border-radius: 8px;
3130
+ background: rgba(255,255,255,0.78);
3131
+ padding: 8px 7px;
3132
+ min-width: 0;
3133
+ }
3134
+
3135
+ .local-audit-stat strong {
3136
+ display: block;
3137
+ font-size: 15px;
3138
+ line-height: 1.05;
3139
+ }
3140
+
3141
+ .local-audit-stat span {
3142
+ display: block;
3143
+ margin-top: 4px;
3144
+ color: var(--faint);
3145
+ font-size: 10px;
3146
+ line-height: 1.2;
3147
+ }
3148
+
3149
+ .local-status-line {
3150
+ color: var(--muted);
3151
+ font-size: 12px;
3152
+ line-height: 1.45;
3153
+ word-break: break-word;
3154
+ }
3155
+
3156
+ .local-status-line.error {
3157
+ color: #a53131;
3158
+ }
3159
+
3160
+ .local-permission {
3161
+ border: 1px solid rgba(245,158,11,0.28);
3162
+ background: rgba(245,158,11,0.09);
3163
+ border-radius: 8px;
3164
+ padding: 9px;
3165
+ display: flex;
3166
+ flex-direction: column;
3167
+ gap: 8px;
3168
+ }
3169
+
2950
3170
  .legend-item,
2951
3171
  .filter-item {
2952
3172
  display: flex;
@@ -14,6 +14,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
14
14
  sidebar_eyebrow: '지식 그래프', sidebar_title: '지식 토폴로지',
15
15
  sidebar_sub: '주제의 크기는 중요도 기반으로, 선의 굵기와 색은 관계 종류와 강도를 반영합니다.',
16
16
  nodes: '노드', edges: '연결', relationship_legend: '관계 범례', node_types: '노드 유형',
17
+ local_sources: '지식 소스', local_notice: 'Lattice AI는 사용자가 선택한 폴더만 AI 지식으로 변환합니다.',
18
+ local_path_ph: '폴더 경로 입력...', local_roots: '드라이브 선택', local_tree: '폴더 구조 확인',
19
+ local_audit: '안전 검사', local_index: '지식 그래프 만들기', local_ocr: '이미지 글자 인식',
20
+ local_watch: '자동 감지 켜기', local_permission: '권한 승인', local_sources_empty: '아직 추가된 지식 소스가 없습니다.',
21
+ local_indexed: '지식 그래프 생성 완료', local_watch_unavailable: '자동 감지는 watchdog 설치 후 작동합니다.',
17
22
  detail_empty: '노드를 클릭하면 요약, 중요도, 연결 강도, 메타데이터를 볼 수 있습니다. 검색 패널에서는 서버 검색 결과를 기준으로 더 정확하게 이동할 수 있습니다.',
18
23
  detail_empty_short: '노드를 클릭하면 요약, 중요도, 메타데이터를 볼 수 있습니다.',
19
24
  refresh: '새로고침', error: '오류', graph_load_fail: '그래프를 불러오지 못했습니다.', graph_refresh_fail: '그래프를 새로고침하지 못했습니다.',
@@ -31,6 +36,11 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
31
36
  sidebar_eyebrow: 'Knowledge Graph', sidebar_title: 'Knowledge topology',
32
37
  sidebar_sub: 'Topic size follows importance; line width and color reflect relationship type and strength.',
33
38
  nodes: 'Nodes', edges: 'Edges', relationship_legend: 'Relationship legend', node_types: 'Node types',
39
+ local_sources: 'Knowledge sources', local_notice: 'Lattice AI only turns folders you choose into AI knowledge.',
40
+ local_path_ph: 'Enter a folder path...', local_roots: 'Drive picker', local_tree: 'Check folders',
41
+ local_audit: 'Safety check', local_index: 'Build graph', local_ocr: 'Image text recognition',
42
+ local_watch: 'Auto watch', local_permission: 'Approve access', local_sources_empty: 'No knowledge sources yet.',
43
+ local_indexed: 'Knowledge graph built', local_watch_unavailable: 'Auto watch works after watchdog is installed.',
34
44
  detail_empty: 'Click a node to see its summary, importance, connection strength, and metadata. Search results can jump to more precise nodes.',
35
45
  detail_empty_short: 'Click a node to see its summary, importance, and metadata.',
36
46
  refresh: 'Refresh', error: 'Error', graph_load_fail: 'Could not load the graph.', graph_refresh_fail: 'Could not refresh the graph.',
@@ -60,8 +70,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
60
70
  document.querySelector('.sidebar-sub').textContent = t('sidebar_sub');
61
71
  document.querySelectorAll('.stat span')[0].textContent = t('nodes');
62
72
  document.querySelectorAll('.stat span')[1].textContent = t('edges');
63
- document.querySelectorAll('.section-label')[0].textContent = t('relationship_legend');
64
- document.querySelectorAll('.section-label')[1].textContent = t('node_types');
73
+ document.getElementById('local-source-label').textContent = t('local_sources');
74
+ document.getElementById('edge-label').textContent = t('relationship_legend');
75
+ document.getElementById('type-label').textContent = t('node_types');
65
76
  document.getElementById('refresh-btn').textContent = `↺ ${t('refresh')}`;
66
77
  const langBtn = document.getElementById('graph-lang-btn');
67
78
  if (langBtn) langBtn.textContent = `Language: ${currentLang === 'ko' ? '한국어' : 'English'}`;
@@ -88,22 +99,32 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
88
99
  renderSearchResults();
89
100
  renderTypeFilters(buildTypeCounts());
90
101
  renderEdgeLegend(buildEdgeCounts());
102
+ renderLocalSources();
91
103
  showDetail(selected);
92
104
  }
93
105
  window.toggleLangMenu = toggleLangMenu;
94
106
  window.setLang = setLang;
95
107
 
96
108
  const TYPE_CONFIG = {
109
+ Computer: { color: '#14b8a6', label: 'Computer' },
110
+ Drive: { color: '#38bdf8', label: 'Drive' },
111
+ Folder: { color: '#f0a500', label: 'Folder' },
97
112
  Conversation: { color: '#9b8af0', label: 'Conversation' },
98
113
  Message: { color: '#b8a9f5', label: 'Message' },
99
114
  AIResponse: { color: '#6f42e8', label: 'AI Response' },
100
115
  File: { color: '#5b9cf6', label: 'File' },
116
+ Document: { color: '#5b9cf6', label: 'Document' },
117
+ CodeFile: { color: '#22c55e', label: 'Code File' },
118
+ Spreadsheet: { color: '#059669', label: 'Spreadsheet' },
119
+ SlideDeck: { color: '#818cf8', label: 'Slide Deck' },
101
120
  Topic: { color: '#7c3aed', label: 'Topic' },
121
+ Concept: { color: '#7c3aed', label: 'Concept' },
102
122
  Person: { color: '#0d9488', label: 'Person' },
103
123
  Page: { color: '#a78bfa', label: 'Page' },
104
124
  Slide: { color: '#818cf8', label: 'Slide' },
105
125
  Sheet: { color: '#059669', label: 'Sheet' },
106
126
  Image: { color: '#d97706', label: 'Image' },
127
+ ImageText: { color: '#f97316', label: 'Image Text' },
107
128
  Decision: { color: '#f59e0b', label: 'Decision' },
108
129
  Task: { color: '#ec4899', label: 'Task' },
109
130
  ClearEvent: { color: '#6366f1', label: 'Clear Event' },
@@ -126,6 +147,9 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
126
147
  has_sheet: { color: '#20b8aa', label: 'Sheet', width: 1.3 },
127
148
  contains_image: { color: '#f1c86d', label: 'Image', width: 1.35 },
128
149
  has_chunk: { color: '#4e566f', label: 'Chunk', width: 0.9, dash: [2, 5] },
150
+ '포함함': { color: '#7186c8', label: 'Contains', width: 1.35 },
151
+ '언급함': { color: '#aebcff', label: 'Mentions', width: 1.45 },
152
+ '관련됨': { color: '#7f8f9d', label: 'Related', width: 1.3 },
129
153
  };
130
154
 
131
155
  const canvas = document.getElementById('graph');
@@ -135,6 +159,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
135
159
  const searchInput = document.getElementById('search');
136
160
  const searchResultsEl = document.getElementById('search-results');
137
161
  const searchCountEl = document.getElementById('search-count');
162
+ const localSourcePanel = document.getElementById('local-source-panel');
138
163
 
139
164
  let rawGraph = { nodes: [], edges: [] };
140
165
  let graph = { nodes: [], edges: [] };
@@ -151,6 +176,20 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
151
176
  let searchResultIds = new Set();
152
177
  let searchAbortController = null;
153
178
  let searchDebounceId = null;
179
+ let localState = {
180
+ roots: [],
181
+ sources: [],
182
+ watch: null,
183
+ selectedPath: '',
184
+ tree: null,
185
+ audit: null,
186
+ includeOcr: false,
187
+ watchEnabled: false,
188
+ busy: false,
189
+ status: '',
190
+ error: '',
191
+ pendingPermission: null,
192
+ };
154
193
 
155
194
  function apiFetch(path, opts = {}) {
156
195
  return fetch(`${API_BASE}${path}`, {
@@ -173,6 +212,266 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
173
212
  .replaceAll("'", '&#39;');
174
213
  }
175
214
 
215
+ function formatCount(value) {
216
+ return Number(value || 0).toLocaleString();
217
+ }
218
+
219
+ async function apiJson(path, payload) {
220
+ return apiFetch(path, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify(payload || {}),
224
+ });
225
+ }
226
+
227
+ async function loadLocalSources() {
228
+ try {
229
+ const [rootsRes, sourcesRes] = await Promise.all([
230
+ apiFetch('/knowledge-graph/local/roots'),
231
+ apiFetch('/knowledge-graph/local/sources'),
232
+ ]);
233
+ if (rootsRes.status === 401 || sourcesRes.status === 401) {
234
+ window.location.href = '/account';
235
+ return;
236
+ }
237
+ const rootsData = rootsRes.ok ? await rootsRes.json() : {};
238
+ const sourcesData = sourcesRes.ok ? await sourcesRes.json() : {};
239
+ localState.roots = Array.isArray(rootsData.roots) ? rootsData.roots : [];
240
+ localState.sources = Array.isArray(sourcesData.sources) ? sourcesData.sources : [];
241
+ localState.watch = sourcesData.watch || null;
242
+ if (!localState.selectedPath && localState.roots[0]) {
243
+ localState.selectedPath = localState.roots[0].path;
244
+ }
245
+ renderLocalSources();
246
+ } catch (error) {
247
+ localState.error = error.message;
248
+ renderLocalSources();
249
+ }
250
+ }
251
+
252
+ function renderLocalSources() {
253
+ if (!localSourcePanel) return;
254
+ const rootRows = localState.roots.slice(0, 8).map(root => {
255
+ const active = root.path === localState.selectedPath ? 'active' : '';
256
+ return `
257
+ <button class="local-root-btn ${active}" onclick="selectLocalPath(decodeURIComponent('${encodeURIComponent(root.path)}'))" title="${escapeHtml(root.path)}">
258
+ <i class="ti ${root.kind === 'drive' || root.kind === 'volume' ? 'ti-device-desktop' : 'ti-folder'}"></i>
259
+ <span class="local-source-main">
260
+ <strong>${escapeHtml(root.label || root.path)}</strong>
261
+ <span>${escapeHtml(root.path)}</span>
262
+ </span>
263
+ ${root.warning ? '<i class="ti ti-alert-triangle"></i>' : ''}
264
+ </button>
265
+ `;
266
+ }).join('');
267
+
268
+ const treeRows = (localState.tree?.items || []).slice(0, 8).map(item => `
269
+ <div class="local-tree-row" title="${escapeHtml(item.path)}">
270
+ <i class="ti ${item.type === 'directory' ? 'ti-folder' : 'ti-file'}"></i>
271
+ <span class="local-tree-main">
272
+ <strong>${escapeHtml(item.name)}</strong>
273
+ <span>${escapeHtml(item.excluded_reason || item.extension || item.type)}</span>
274
+ </span>
275
+ ${item.accessible === false ? '<i class="ti ti-lock"></i>' : ''}
276
+ </div>
277
+ `).join('');
278
+
279
+ const summary = localState.audit?.summary || null;
280
+ const auditHtml = summary ? `
281
+ <div class="local-audit-grid">
282
+ <div class="local-audit-stat"><strong>${formatCount(summary.readable_files)}</strong><span>읽을 파일</span></div>
283
+ <div class="local-audit-stat"><strong>${formatCount(summary.sensitive_files)}</strong><span>민감 제외</span></div>
284
+ <div class="local-audit-stat"><strong>${formatCount(summary.unsupported_files)}</strong><span>미지원</span></div>
285
+ <div class="local-audit-stat"><strong>${formatCount(summary.too_large_files)}</strong><span>너무 큼</span></div>
286
+ <div class="local-audit-stat"><strong>${formatCount(summary.image_ocr_candidates)}</strong><span>이미지</span></div>
287
+ <div class="local-audit-stat"><strong>${formatCount(summary.estimated_seconds)}</strong><span>예상 초</span></div>
288
+ </div>
289
+ ` : '';
290
+
291
+ const permissionHtml = localState.pendingPermission ? `
292
+ <div class="local-permission">
293
+ <div class="local-status-line">${escapeHtml(localState.pendingPermission.message || '')}</div>
294
+ <button class="local-source-btn primary" onclick="approveLocalPermission()">
295
+ <i class="ti ti-shield-check"></i>${t('local_permission')}
296
+ </button>
297
+ </div>
298
+ ` : '';
299
+
300
+ const sourceRows = localState.sources.slice(0, 4).map(source => {
301
+ const status = source.watch_active ? '자동 감지 중' : (source.watch_enabled ? '자동 감지 대기' : '수동 반영');
302
+ return `
303
+ <div class="local-source-row" title="${escapeHtml(source.root_path)}">
304
+ <i class="ti ti-database"></i>
305
+ <span class="local-source-main">
306
+ <strong>${escapeHtml(source.label || source.root_path)}</strong>
307
+ <span>${escapeHtml(status)} · ${escapeHtml(source.root_path)}</span>
308
+ </span>
309
+ <span>${formatCount((source.file_status || {}).indexed)}</span>
310
+ </div>
311
+ `;
312
+ }).join('');
313
+
314
+ const watchWarning = localState.watch && localState.watch.available === false
315
+ ? `<div class="local-status-line">${t('local_watch_unavailable')}</div>`
316
+ : '';
317
+ const statusClass = localState.error ? ' error' : '';
318
+ const statusText = localState.error || localState.status || '';
319
+
320
+ localSourcePanel.innerHTML = `
321
+ <div class="local-source-notice">${t('local_notice')}</div>
322
+ <div class="local-source-input">
323
+ <input id="local-path-input" value="${escapeHtml(localState.selectedPath)}" placeholder="${t('local_path_ph')}" oninput="updateLocalPath(this.value)">
324
+ </div>
325
+ ${rootRows ? `<div class="local-root-list">${rootRows}</div>` : ''}
326
+ <div class="local-option-row">
327
+ <label><input type="checkbox" ${localState.includeOcr ? 'checked' : ''} onchange="setLocalOption('includeOcr', this.checked)"> ${t('local_ocr')}</label>
328
+ <label><input type="checkbox" ${localState.watchEnabled ? 'checked' : ''} onchange="setLocalOption('watchEnabled', this.checked)"> ${t('local_watch')}</label>
329
+ </div>
330
+ <div class="local-source-actions">
331
+ <button class="local-source-btn" ${localState.busy ? 'disabled' : ''} onclick="runLocalTree()" title="${t('local_tree')}"><i class="ti ti-folders"></i>${t('local_tree')}</button>
332
+ <button class="local-source-btn" ${localState.busy ? 'disabled' : ''} onclick="runLocalAudit()" title="${t('local_audit')}"><i class="ti ti-shield-search"></i>${t('local_audit')}</button>
333
+ <button class="local-source-btn primary" ${localState.busy ? 'disabled' : ''} onclick="runLocalIndex()" title="${t('local_index')}"><i class="ti ti-chart-dots-3"></i>${t('local_index')}</button>
334
+ </div>
335
+ ${permissionHtml}
336
+ ${statusText ? `<div class="local-status-line${statusClass}">${escapeHtml(statusText)}</div>` : ''}
337
+ ${watchWarning}
338
+ ${auditHtml}
339
+ ${treeRows ? `<div class="local-tree-list">${treeRows}</div>` : ''}
340
+ <div class="local-source-list">
341
+ ${sourceRows || `<div class="local-status-line">${t('local_sources_empty')}</div>`}
342
+ </div>
343
+ `;
344
+ }
345
+
346
+ function selectLocalPath(path) {
347
+ localState.selectedPath = path;
348
+ localState.tree = null;
349
+ localState.audit = null;
350
+ localState.error = '';
351
+ localState.status = '';
352
+ renderLocalSources();
353
+ }
354
+
355
+ function updateLocalPath(path) {
356
+ localState.selectedPath = path;
357
+ }
358
+
359
+ function setLocalOption(key, value) {
360
+ localState[key] = Boolean(value);
361
+ renderLocalSources();
362
+ }
363
+
364
+ async function runLocalRequest(endpoint, payload, onSuccess) {
365
+ if (!localState.selectedPath) return;
366
+ localState.busy = true;
367
+ localState.error = '';
368
+ localState.status = '';
369
+ localState.pendingPermission = null;
370
+ renderLocalSources();
371
+ try {
372
+ const res = await apiJson(endpoint, payload);
373
+ if (res.status === 401) {
374
+ window.location.href = '/account';
375
+ return;
376
+ }
377
+ const data = await res.json();
378
+ if (data.permission_required) {
379
+ localState.pendingPermission = { endpoint, payload, ...data };
380
+ localState.busy = false;
381
+ renderLocalSources();
382
+ return;
383
+ }
384
+ if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
385
+ await onSuccess(data);
386
+ } catch (error) {
387
+ localState.error = error.message;
388
+ } finally {
389
+ localState.busy = false;
390
+ renderLocalSources();
391
+ }
392
+ }
393
+
394
+ async function approveLocalPermission() {
395
+ const pending = localState.pendingPermission;
396
+ if (!pending) return;
397
+ localState.busy = true;
398
+ renderLocalSources();
399
+ try {
400
+ const approveRes = await apiFetch(`/permissions/approve/${encodeURIComponent(pending.approval_token)}`, { method: 'POST' });
401
+ const approveData = await approveRes.json().catch(() => ({}));
402
+ if (!approveRes.ok) throw new Error(approveData.detail || `Approval failed (${approveRes.status})`);
403
+ const payload = { ...pending.payload, approved: true, approval_token: pending.approval_token };
404
+ const res = await apiJson(pending.endpoint, payload);
405
+ const data = await res.json();
406
+ if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
407
+ localState.pendingPermission = null;
408
+ if (pending.endpoint.endsWith('/tree')) {
409
+ localState.tree = data;
410
+ localState.status = data.privacy_notice || '';
411
+ } else if (pending.endpoint.endsWith('/audit')) {
412
+ localState.audit = data;
413
+ localState.status = data.privacy_notice || '';
414
+ } else if (pending.endpoint.endsWith('/index')) {
415
+ localState.status = `${t('local_indexed')} · ${formatCount((data.counts || {}).indexed)} files`;
416
+ await Promise.all([loadGraph(), loadLocalSources()]);
417
+ return;
418
+ }
419
+ } catch (error) {
420
+ localState.error = error.message;
421
+ } finally {
422
+ localState.busy = false;
423
+ renderLocalSources();
424
+ }
425
+ }
426
+
427
+ function runLocalTree() {
428
+ runLocalRequest('/knowledge-graph/local/tree', {
429
+ path: localState.selectedPath,
430
+ max_items: 120,
431
+ }, data => {
432
+ localState.tree = data;
433
+ localState.status = data.privacy_notice || '';
434
+ });
435
+ }
436
+
437
+ function runLocalAudit() {
438
+ runLocalRequest('/knowledge-graph/local/audit', {
439
+ path: localState.selectedPath,
440
+ include_ocr: localState.includeOcr,
441
+ max_files: 50000,
442
+ }, data => {
443
+ localState.audit = data;
444
+ localState.status = data.privacy_notice || '';
445
+ });
446
+ }
447
+
448
+ function runLocalIndex() {
449
+ runLocalRequest('/knowledge-graph/local/index', {
450
+ path: localState.selectedPath,
451
+ include_ocr: localState.includeOcr,
452
+ watch_enabled: localState.watchEnabled,
453
+ max_files: 5000,
454
+ consent: {
455
+ ui: 'graph',
456
+ knowledge_source: true,
457
+ image_ocr: localState.includeOcr,
458
+ watch_enabled: localState.watchEnabled,
459
+ sensitive_files_default_excluded: true,
460
+ },
461
+ }, async data => {
462
+ localState.status = `${t('local_indexed')} · ${formatCount((data.counts || {}).indexed)} files`;
463
+ await Promise.all([loadGraph(), loadLocalSources()]);
464
+ });
465
+ }
466
+
467
+ window.selectLocalPath = selectLocalPath;
468
+ window.updateLocalPath = updateLocalPath;
469
+ window.setLocalOption = setLocalOption;
470
+ window.runLocalTree = runLocalTree;
471
+ window.runLocalAudit = runLocalAudit;
472
+ window.runLocalIndex = runLocalIndex;
473
+ window.approveLocalPermission = approveLocalPermission;
474
+
176
475
  function nodeColor(type) {
177
476
  return (TYPE_CONFIG[type] || {}).color || '#8fa8bb';
178
477
  }
@@ -712,7 +1011,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
712
1011
  : '';
713
1012
  const metrics = metricCards(node);
714
1013
  const updatedAt = formatUpdatedAt(node.updated_at);
715
- const source = meta.filename || meta.conversation_id || meta.source || '';
1014
+ const source = meta.relative_path || meta.filename || meta.conversation_id || meta.source || '';
716
1015
  const metadataStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
717
1016
  detail.innerHTML = `
718
1017
  <div class="type-badge" style="background:${nodeColor(node.type)}">${escapeHtml(typeLabel(node.type))}</div>
@@ -756,7 +1055,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
756
1055
  <div class="search-list">
757
1056
  ${searchResults.map(match => {
758
1057
  const active = selected && selected.id === match.id ? 'active' : '';
759
- const source = (match.metadata || {}).filename || (match.metadata || {}).conversation_id || '';
1058
+ const source = (match.metadata || {}).relative_path || (match.metadata || {}).filename || (match.metadata || {}).conversation_id || '';
760
1059
  return `
761
1060
  <button class="search-item ${active}" data-node-id="${escapeHtml(match.id)}">
762
1061
  <div class="search-item-top">
@@ -1050,6 +1349,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
1050
1349
  resize();
1051
1350
  applyI18n();
1052
1351
  renderSearchResults();
1352
+ renderLocalSources();
1353
+ loadLocalSources();
1053
1354
  loadGraph().catch(error => {
1054
1355
  detail.innerHTML = `
1055
1356
  <div class="type-badge" style="background:${nodeColor('ClearEvent')}">${t('error')}</div>