dhammawatthumpra commited on
Commit
165565d
·
1 Parent(s): 23b4d13

feat: normalize reference extraction and popup UI

Browse files
webapp/tipitaka-api/app/database/sqlite_db.py CHANGED
@@ -82,6 +82,24 @@ class SQLiteDB:
82
  conn.execute("CREATE INDEX IF NOT EXISTS idx_search_log_query ON search_log(query)")
83
  conn.commit()
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  @property
86
  def is_in_memory(self) -> bool:
87
  return self._mem_conn is not None
@@ -97,4 +115,6 @@ def get_db() -> SQLiteDB:
97
  if _db is None:
98
  settings = get_settings()
99
  _db = SQLiteDB(settings.DATABASE_PATH)
 
 
100
  return _db
 
82
  conn.execute("CREATE INDEX IF NOT EXISTS idx_search_log_query ON search_log(query)")
83
  conn.commit()
84
 
85
+ def ensure_reference_tables(self) -> None:
86
+ """Create reference_markers table on disk if not exists."""
87
+ with sqlite3.connect(self.db_path) as conn:
88
+ conn.execute("""
89
+ CREATE TABLE IF NOT EXISTS reference_markers (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ volume_num INTEGER NOT NULL,
92
+ page_num INTEGER NOT NULL,
93
+ marker_id TEXT NOT NULL,
94
+ type TEXT NOT NULL, -- 'footnote' | 'abbrev'
95
+ content TEXT NOT NULL,
96
+ UNIQUE(volume_num, page_num, marker_id, type)
97
+ )
98
+ """)
99
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_ref_vol_page ON reference_markers(volume_num, page_num)")
100
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_ref_marker ON reference_markers(marker_id)")
101
+ conn.commit()
102
+
103
  @property
104
  def is_in_memory(self) -> bool:
105
  return self._mem_conn is not None
 
115
  if _db is None:
116
  settings = get_settings()
117
  _db = SQLiteDB(settings.DATABASE_PATH)
118
+ _db.ensure_search_log_table()
119
+ _db.ensure_reference_tables()
120
  return _db
webapp/tipitaka-api/app/routers/reference.py CHANGED
@@ -18,8 +18,8 @@ class ReferenceLookupRequest(BaseModel):
18
  def get_page_service(db: SQLiteDB = Depends(get_db)) -> PageService:
19
  return PageService(db)
20
 
21
- def get_llm_service() -> LLMService:
22
- return LLMService()
23
 
24
  @router.get("/lookup")
25
  async def lookup_reference(
@@ -37,12 +37,11 @@ async def lookup_reference(
37
  return {"content": content, "found": True}
38
 
39
  elif type == "abbrev":
40
- # Abbreviation expansion usually needs context
41
- # We fetch the page text if not provided
42
  page_data = service.get_page(vol, page)
43
  context = page_data["content_text"] if page_data else ""
44
 
45
- expansion = await llm.expand_abbreviation(context, id)
46
  return {"content": expansion, "found": True}
47
 
48
  else:
 
18
  def get_page_service(db: SQLiteDB = Depends(get_db)) -> PageService:
19
  return PageService(db)
20
 
21
+ def get_llm_service(db: SQLiteDB = Depends(get_db)) -> LLMService:
22
+ return LLMService(db)
23
 
24
  @router.get("/lookup")
25
  async def lookup_reference(
 
37
  return {"content": content, "found": True}
38
 
39
  elif type == "abbrev":
40
+ # Fetch page text for LLM context if DB index fails
 
41
  page_data = service.get_page(vol, page)
42
  context = page_data["content_text"] if page_data else ""
43
 
44
+ expansion = await llm.expand_abbreviation(vol, page, id, context)
45
  return {"content": expansion, "found": True}
46
 
47
  else:
webapp/tipitaka-api/app/scripts/index_references.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import sqlite3
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ # Add app to path to import services/db
8
+ sys.path.append(str(Path(__file__).parent.parent.parent))
9
+
10
+ from app.database.sqlite_db import get_db
11
+
12
+ def extract_references():
13
+ db = get_db()
14
+ print(f"Starting reference indexing on {db.db_path}...")
15
+
16
+ # We use disk connection for writes
17
+ with db.get_disk_connection() as conn:
18
+ cursor = conn.cursor()
19
+
20
+ # 1. Get all pages
21
+ cursor.execute("""
22
+ SELECT p.id, v.volume_number, p.page_number, p.content_html
23
+ FROM pages p
24
+ JOIN volumes v ON p.volume_id = v.id
25
+ WHERE p.content_html IS NOT NULL
26
+ """)
27
+
28
+ pages = cursor.fetchall()
29
+ total = len(pages)
30
+ print(f"Processing {total} pages...")
31
+
32
+ indexed_count = 0
33
+ for i, page in enumerate(pages):
34
+ vol_num = page["volume_number"]
35
+ page_num = page["page_number"]
36
+ raw_html = page["content_html"]
37
+
38
+ # Extract footnotes (@ markers)
39
+ # Strip line numbers first
40
+ fn_html = re.sub(r'<span\s+class="LineNumber">.*?</span>', '', raw_html)
41
+ lines = fn_html.split('\n')
42
+
43
+ current_fn = None
44
+ for line in lines:
45
+ line_strip = line.strip()
46
+ if line_strip.startswith('@'):
47
+ line_plain = re.sub(r'<[^>]+>', '', line_strip)
48
+ # Match @ marker content
49
+ m = re.match(r'^@\s*([(\[]?[\u0E50-\u0E59\d]+[)\]-]?)\s*(.*)', line_plain)
50
+ if m:
51
+ marker = m.group(1).strip()
52
+ text = m.group(2).strip()
53
+ clean_marker = re.sub(r'[()\[\]-]', '', marker).strip()
54
+
55
+ if clean_marker and not re.search(r'เชิงอรรถ', marker):
56
+ # Save current if exists
57
+ if current_fn:
58
+ save_ref(cursor, vol_num, page_num, current_fn["id"], "footnote", current_fn["content"])
59
+ indexed_count += 1
60
+
61
+ current_fn = {"id": clean_marker, "content": text}
62
+ elif current_fn:
63
+ # Continuation line starting with @
64
+ current_fn["content"] += " " + line_plain.lstrip('@').strip()
65
+ elif current_fn:
66
+ # Non-@ line ends the footnote block
67
+ save_ref(cursor, vol_num, page_num, current_fn["id"], "footnote", current_fn["content"])
68
+ indexed_count += 1
69
+ current_fn = None
70
+
71
+ if current_fn:
72
+ save_ref(cursor, vol_num, page_num, current_fn["id"], "footnote", current_fn["content"])
73
+ indexed_count += 1
74
+
75
+ # Extract abbreviations (ย่อ) - this is harder as they are often just (ย่อ) in text
76
+ # For now, we can look for specific (ย่อ) patterns that might have been manually defined
77
+ # or common ones we want to pre-cache.
78
+ # In the current DB, most (ย่อ) are in-line.
79
+ # If there are any @(ย่อ) or similar, we catch them above.
80
+
81
+ if i % 1000 == 0:
82
+ print(f"Processed {i}/{total} pages... Indexed {indexed_count} markers")
83
+ conn.commit()
84
+
85
+ conn.commit()
86
+ print(f"Finished! Total indexed: {indexed_count}")
87
+
88
+ def save_ref(cursor, vol, page, marker_id, ref_type, content):
89
+ try:
90
+ cursor.execute("""
91
+ INSERT OR REPLACE INTO reference_markers (volume_num, page_num, marker_id, type, content)
92
+ VALUES (?, ?, ?, ?, ?)
93
+ """, (vol, page, marker_id, ref_type, content.strip()))
94
+ except Exception as e:
95
+ print(f"Error saving ref {vol}:{page} {marker_id}: {e}")
96
+
97
+ if __name__ == "__main__":
98
+ extract_references()
webapp/tipitaka-api/app/services/llm_service.py CHANGED
@@ -39,8 +39,11 @@ SYSTEM_PROMPT = (
39
  )
40
 
41
 
 
 
 
42
  class LLMService:
43
- def __init__(self):
44
  settings = get_settings()
45
  self.client = AsyncOpenAI(
46
  api_key=settings.LLM_API_KEY,
@@ -52,6 +55,8 @@ class LLMService:
52
  "reasoner": settings.LLM_MODEL_REASONER,
53
  }
54
  self.rag_service = RAGService()
 
 
55
 
56
  def _resolve_model(self, mode: str) -> str:
57
  return self.model_map.get(mode, self.model_map["fast"])
@@ -110,14 +115,30 @@ class LLMService:
110
 
111
  except Exception as e:
112
  yield {'data': json.dumps({'error': str(e)})}
113
- async def expand_abbreviation(self, context: str, abbrev_text: str) -> str:
114
- """Use LLM to expand abbreviation (ย่อ) based on context."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  prompt = (
116
  f"คุณคือผู้เชี่ยวชาญพระไตรปิฎก มจร. "
117
  f"ในเนื้อหาต่อไปนี้มีคำว่า '{abbrev_text}' (เช่น (ย่อ), ฯลฯ) "
118
  f"กรุณาอธิบายหรือขยายความสิ่งที่ถูกย่อไว้ให้สมบูรณ์ที่สุดตามหลักฐานในพระไตรปิฎก "
119
  f"โดยพิจารณาจากบริบทแวดล้อมที่ให้มา:\n\n"
120
- f"เนื้อหาบริบท:\n{context[:4000]}\n\n"
121
  f"กรุณาตอบเป็นข้อความสั้นๆ ที่เป็นเนื้อหาที่ถูกย่อไว้ หรืออธิบายว่าส่วนนี้ย่อมาจากอะไร "
122
  f"ถ้าไม่แน่ใจให้บอกว่าเป็นการย่อเพื่อละเนื้อหาที่ซ้ำกัน"
123
  )
 
39
  )
40
 
41
 
42
+ from app.database.sqlite_db import SQLiteDB, get_db
43
+ from app.services.search_service import SearchService
44
+
45
  class LLMService:
46
+ def __init__(self, db: Optional[SQLiteDB] = None):
47
  settings = get_settings()
48
  self.client = AsyncOpenAI(
49
  api_key=settings.LLM_API_KEY,
 
55
  "reasoner": settings.LLM_MODEL_REASONER,
56
  }
57
  self.rag_service = RAGService()
58
+ self.db = db or get_db()
59
+ self.search_service = SearchService(self.db)
60
 
61
  def _resolve_model(self, mode: str) -> str:
62
  return self.model_map.get(mode, self.model_map["fast"])
 
115
 
116
  except Exception as e:
117
  yield {'data': json.dumps({'error': str(e)})}
118
+
119
+ async def expand_abbreviation(self, vol: int, page: int, abbrev_text: str, context: str = "") -> str:
120
+ """Expand abbreviation (ย่อ) using DB index or LLM as fallback."""
121
+ clean_id = re.sub(r'[()\[\]-]', '', abbrev_text).strip()
122
+
123
+ # 1. Try DB Index
124
+ with self.db.get_connection() as conn:
125
+ cursor = conn.cursor()
126
+ cursor.execute("""
127
+ SELECT content FROM reference_markers
128
+ WHERE volume_num = ? AND page_num = ? AND marker_id = ? AND type = 'abbrev'
129
+ """, (vol, page, clean_id))
130
+ row = cursor.fetchone()
131
+ if row:
132
+ return row["content"]
133
+
134
+ # 2. LLM Fallback
135
+ clean_ctx = self.search_service.clean_text(context) if context else ""
136
  prompt = (
137
  f"คุณคือผู้เชี่ยวชาญพระไตรปิฎก มจร. "
138
  f"ในเนื้อหาต่อไปนี้มีคำว่า '{abbrev_text}' (เช่น (ย่อ), ฯลฯ) "
139
  f"กรุณาอธิบายหรือขยายความสิ่งที่ถูกย่อไว้ให้สมบูรณ์ที่สุดตามหลักฐานในพระไตรปิฎก "
140
  f"โดยพิจารณาจากบริบทแวดล้อมที่ให้มา:\n\n"
141
+ f"เนื้อหาบริบท:\n{clean_ctx[:4000]}\n\n"
142
  f"กรุณาตอบเป็นข้อความสั้นๆ ที่เป็นเนื้อหาที่ถูกย่อไว้ หรืออธิบายว่าส่วนนี้ย่อมาจากอะไร "
143
  f"ถ้าไม่แน่ใจให้บอกว่าเป็นการย่อเพื่อละเนื้อหาที่ซ้ำกัน"
144
  )
webapp/tipitaka-api/app/services/page_service.py CHANGED
@@ -152,9 +152,16 @@ class PageService:
152
 
153
  def apply_inline_refs(text: str) -> str:
154
  """Wrap inline (Thai_digit) and (ย่อ) references as superscript."""
 
 
 
 
 
 
 
155
  text = re.sub(
156
  r'(?<=\S)\s*\(([\u0E50-\u0E59]+|\d+)\)',
157
- r'<sup class="footnote-ref">(\1)</sup>',
158
  text
159
  )
160
  text = re.sub(
@@ -178,11 +185,12 @@ class PageService:
178
  if re.match(footnote_pattern, line):
179
  flush_para()
180
  marker = line.strip()
 
181
  # Attach inline to the previous <p> so it doesn't create a gap
182
  if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
183
- wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup class="footnote-ref">{marker}</sup></p>'
184
  else:
185
- wrapped_parts.append(f'<p class="footnote-ref">{marker}</p>')
186
  elif re.match(abbrev_pattern, line):
187
  # (ย่อ) / ๓ (ย่อ) / (๓) (ย่อ) — split into separate sups:
188
  # number part → footnote-ref, (ย่อ) → abbrev-ref
@@ -372,59 +380,38 @@ class PageService:
372
  columns = [c[0] for c in cursor.description]
373
  return [dict(zip(columns, row)) for row in cursor.fetchall()]
374
  def get_footnote(self, volume_number: int, page_number: int, footnote_id: str) -> Optional[str]:
375
- """Extract footnote definition from raw HTML based on footnote_id."""
 
 
 
 
 
376
  with self.db.get_connection() as conn:
377
  cursor = conn.cursor()
 
 
378
  cursor.execute("""
379
- SELECT p.content_html
380
- FROM pages p
381
- JOIN volumes v ON p.volume_id = v.id
382
- WHERE v.volume_number = ? AND p.page_number = ?
383
- """, (volume_number, page_number))
384
  row = cursor.fetchone()
385
- if not row or not row["content_html"]:
386
- return None
387
-
388
- raw_html = row["content_html"]
389
- lines = raw_html.split('\n')
390
 
391
- # Clean footnote_id (e.g. "()" -> "๑", "๑-" -> "๑")
392
- clean_id = re.sub(r'[()\[\]-]', '', footnote_id).strip()
393
-
394
- # Patterns to match definition start: @๑, @(๑), @[๑]
395
- # or just at the bottom (though @ is more reliable in MCU)
396
- found_lines = []
397
- capturing = False
398
-
399
- for line in lines:
400
- line_strip = line.strip()
401
- # Remove tags for matching to handle @<I>๑</I>
402
- line_plain = re.sub(r'<[^>]+>', '', line_strip)
403
-
404
- # Definition start pattern: matches @ followed by optional bracket/paren and the id
405
- # e.g. @๑, @(๑), @[], @๑-
406
- start_pattern = rf'^@\s*[(\[]?{re.escape(clean_id)}[)\]-]?.*'
407
 
408
- if re.match(start_pattern, line_plain):
409
- capturing = True
410
- # Strip the @ and marker part from the plain text to get content
411
- content = re.sub(rf'^@\s*[(\[]?{re.escape(clean_id)}[)\]-]?\s*', '', line_plain)
412
- if content:
413
- found_lines.append(content)
414
- elif capturing:
415
- if line_strip.startswith('@'):
416
- # Check if it's the next footnote or just a continuation line
417
- # Continuation lines also start with @ but usually don't have a new marker immediately
418
- next_marker = re.match(r'^@\s*[(\[]?[\u0E50-\u0E59\d]+[)\]-]?\s+\S', line_strip)
419
- if next_marker:
420
- break # Found next footnote
421
- else:
422
- # Continuation line
423
- found_lines.append(line_strip.lstrip('@').strip())
424
- else:
425
- # Non-@ line ends the footnote block
426
- break
427
-
428
- if found_lines:
429
- return " ".join(found_lines)
430
  return None
 
152
 
153
  def apply_inline_refs(text: str) -> str:
154
  """Wrap inline (Thai_digit) and (ย่อ) references as superscript."""
155
+ # Use a counter or some logic if we need multiple unique refs on one page?
156
+ # For now, just using the ID itself is usually enough unless the same ref appears twice.
157
+ def _wrap_fn(m):
158
+ val = m.group(1)
159
+ clean = re.sub(r'[()\[\]-]', '', val).strip()
160
+ return f'<sup id="ref-{clean}" class="footnote-ref">({val})</sup>'
161
+
162
  text = re.sub(
163
  r'(?<=\S)\s*\(([\u0E50-\u0E59]+|\d+)\)',
164
+ _wrap_fn,
165
  text
166
  )
167
  text = re.sub(
 
185
  if re.match(footnote_pattern, line):
186
  flush_para()
187
  marker = line.strip()
188
+ clean = re.sub(r'[()\[\]-]', '', marker).strip()
189
  # Attach inline to the previous <p> so it doesn't create a gap
190
  if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
191
+ wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup id="ref-{clean}" class="footnote-ref">{marker}</sup></p>'
192
  else:
193
+ wrapped_parts.append(f'<p class="footnote-ref"><sup id="ref-{clean}">{marker}</sup></p>')
194
  elif re.match(abbrev_pattern, line):
195
  # (ย่อ) / ๓ (ย่อ) / (๓) (ย่อ) — split into separate sups:
196
  # number part → footnote-ref, (ย่อ) → abbrev-ref
 
380
  columns = [c[0] for c in cursor.description]
381
  return [dict(zip(columns, row)) for row in cursor.fetchall()]
382
  def get_footnote(self, volume_number: int, page_number: int, footnote_id: str) -> Optional[str]:
383
+ """Lookup footnote from indexed reference_markers table."""
384
+ # Clean footnote_id (e.g. "(๑)" -> "๑", "๑-" -> "๑")
385
+ clean_id = re.sub(r'[()\[\]-]', '', footnote_id).strip()
386
+ if not clean_id:
387
+ return None
388
+
389
  with self.db.get_connection() as conn:
390
  cursor = conn.cursor()
391
+
392
+ # 1. Try exact page first
393
  cursor.execute("""
394
+ SELECT content FROM reference_markers
395
+ WHERE volume_num = ? AND page_num = ? AND marker_id = ? AND type = 'footnote'
396
+ """, (volume_number, page_number, clean_id))
 
 
397
  row = cursor.fetchone()
398
+ if row:
399
+ return row["content"]
 
 
 
400
 
401
+ # 2. Try window search (+/- 2 pages) to handle markers defined nearby
402
+ # This is common in MCU where a footnote ref on page X might be defined at the start of page X+1
403
+ # or end of page X-1.
404
+ cursor.execute("""
405
+ SELECT content, page_num FROM reference_markers
406
+ WHERE volume_num = ?
407
+ AND page_num BETWEEN ? AND ?
408
+ AND marker_id = ?
409
+ AND type = 'footnote'
410
+ ORDER BY ABS(page_num - ?) ASC
411
+ LIMIT 1
412
+ """, (volume_number, page_number - 2, page_number + 2, clean_id, page_number))
413
+ row = cursor.fetchone()
414
+ if row:
415
+ return row["content"]
 
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  return None
webapp/tipitaka-web/src/components/common/ErrorBoundary.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { Component, ErrorInfo, ReactNode } from 'react';
2
+
3
+ interface Props {
4
+ children: ReactNode;
5
+ fallback?: ReactNode;
6
+ componentName?: string;
7
+ }
8
+
9
+ interface State {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
13
+
14
+ class ErrorBoundary extends Component<Props, State> {
15
+ public state: State = {
16
+ hasError: false,
17
+ error: null
18
+ };
19
+
20
+ public static getDerivedStateFromError(error: Error): State {
21
+ // Update state so the next render will show the fallback UI.
22
+ return { hasError: true, error };
23
+ }
24
+
25
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
26
+ console.error(`Uncaught error in ${this.props.componentName || 'component'}:`, error, errorInfo);
27
+ }
28
+
29
+ public render() {
30
+ if (this.state.hasError) {
31
+ if (this.props.fallback) {
32
+ return this.props.fallback;
33
+ }
34
+
35
+ return (
36
+ <div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
37
+ <div className="flex items-center gap-2 mb-2">
38
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
39
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
40
+ </svg>
41
+ <span className="font-bold">เกิดข้อผิดพลาดในการแสดงผล</span>
42
+ </div>
43
+ <p className="opacity-80 leading-relaxed">
44
+ มีบางอย่างผิดพลาดในส่วนนี้ของแอปพลิเคชัน
45
+ {this.props.componentName && ` (${this.props.componentName})`}
46
+ </p>
47
+ <button
48
+ onClick={() => this.setState({ hasError: false, error: null })}
49
+ className="mt-3 text-xs font-medium underline hover:no-underline opacity-60 hover:opacity-100"
50
+ >
51
+ ลองใหม่อีกครั้ง
52
+ </button>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return this.props.children;
58
+ }
59
+ }
60
+
61
+ export default ErrorBoundary;
webapp/tipitaka-web/src/components/layout/AppShell.tsx CHANGED
@@ -4,6 +4,7 @@ import ReaderPanel from './ReaderPanel';
4
  import RightToolbar from '../toolbar/RightToolbar';
5
  import MobileBottomBar from './MobileBottomBar';
6
  import AIPopup from '../ai/AIPopup';
 
7
  import { Menu } from 'lucide-react';
8
  import { useUIStore, useThemeStore } from '../../stores/appStore';
9
  import { useAIStore } from '../../stores/aiStore';
@@ -79,7 +80,9 @@ const AppShell: React.FC = () => {
79
 
80
  {/* Scrollable reader — id is referenced by RightToolbar scroll-to-top */}
81
  <div id="reader-scroll-container" className="flex-1 overflow-y-auto scroll-smooth">
82
- <ReaderPanel />
 
 
83
  </div>
84
  </div>
85
 
@@ -102,7 +105,11 @@ const AppShell: React.FC = () => {
102
 
103
  {/* AI Popup */}
104
  <AnimatePresence>
105
- {isOpen && <AIPopup />}
 
 
 
 
106
  </AnimatePresence>
107
  </div>
108
  );
 
4
  import RightToolbar from '../toolbar/RightToolbar';
5
  import MobileBottomBar from './MobileBottomBar';
6
  import AIPopup from '../ai/AIPopup';
7
+ import ErrorBoundary from '../common/ErrorBoundary';
8
  import { Menu } from 'lucide-react';
9
  import { useUIStore, useThemeStore } from '../../stores/appStore';
10
  import { useAIStore } from '../../stores/aiStore';
 
80
 
81
  {/* Scrollable reader — id is referenced by RightToolbar scroll-to-top */}
82
  <div id="reader-scroll-container" className="flex-1 overflow-y-auto scroll-smooth">
83
+ <ErrorBoundary componentName="ReaderPanel">
84
+ <ReaderPanel />
85
+ </ErrorBoundary>
86
  </div>
87
  </div>
88
 
 
105
 
106
  {/* AI Popup */}
107
  <AnimatePresence>
108
+ {isOpen && (
109
+ <ErrorBoundary componentName="AIPopup">
110
+ <AIPopup />
111
+ </ErrorBoundary>
112
+ )}
113
  </AnimatePresence>
114
  </div>
115
  );
webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx CHANGED
@@ -4,6 +4,7 @@ import api from '../../lib/api';
4
  import { motion, AnimatePresence } from 'framer-motion';
5
  import SelectionPopup from '../reader/SelectionPopup';
6
  import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
 
7
  import { useSwipeNav } from '../../hooks/useSwipeNav';
8
 
9
  const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
@@ -160,10 +161,31 @@ const ReaderPanel: React.FC = () => {
160
  }
161
  };
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  return (
164
  <div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
165
  <SelectionPopup />
166
- <ReferencePopup pos={refPos} onClose={() => setRefPos(null)} />
 
 
 
 
 
 
167
  <div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
168
  <AnimatePresence mode="wait">
169
  {loading ? (
@@ -222,12 +244,21 @@ const ReaderPanel: React.FC = () => {
222
  เชิงอรรถ (Footnotes)
223
  </h5>
224
  <div className="space-y-4">
225
- {content.footnotes.map((fn: any, idx: number) => (
226
- <div key={idx} className="flex gap-3 text-[13px] leading-relaxed text-white/50 hover:text-white/80 transition-colors">
227
- <span className="text-[#c8860a] font-bold min-w-[24px] text-right shrink-0">{fn.id}</span>
228
- <span className="font-light">{fn.content}</span>
229
- </div>
230
- ))}
 
 
 
 
 
 
 
 
 
231
  </div>
232
  </div>
233
  )}
 
4
  import { motion, AnimatePresence } from 'framer-motion';
5
  import SelectionPopup from '../reader/SelectionPopup';
6
  import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
7
+ import ErrorBoundary from '../common/ErrorBoundary';
8
  import { useSwipeNav } from '../../hooks/useSwipeNav';
9
 
10
  const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
 
161
  }
162
  };
163
 
164
+ const scrollToElement = (id: string) => {
165
+ const element = document.getElementById(id);
166
+ if (element) {
167
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
168
+ element.classList.add('bg-[#c8860a]/20');
169
+ setTimeout(() => element.classList.remove('bg-[#c8860a]/20'), 2000);
170
+ }
171
+ };
172
+
173
+ const scrollToFootnote = (fnId: string) => {
174
+ const cleanId = fnId.replace(/[()\[\]-]/g, '').trim();
175
+ scrollToElement(`fn-item-${cleanId}`);
176
+ setRefPos(null); // Close popup after jump
177
+ };
178
+
179
  return (
180
  <div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
181
  <SelectionPopup />
182
+ <ErrorBoundary componentName="ReferencePopup">
183
+ <ReferencePopup
184
+ pos={refPos}
185
+ onClose={() => setRefPos(null)}
186
+ onJump={scrollToFootnote}
187
+ />
188
+ </ErrorBoundary>
189
  <div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
190
  <AnimatePresence mode="wait">
191
  {loading ? (
 
244
  เชิงอรรถ (Footnotes)
245
  </h5>
246
  <div className="space-y-4">
247
+ {content.footnotes.map((fn: any, idx: number) => {
248
+ const cleanId = fn.id.replace(/[()\[\]-]/g, '').trim();
249
+ return (
250
+ <div
251
+ key={idx}
252
+ id={`fn-item-${cleanId}`}
253
+ className="flex gap-3 text-[13px] leading-relaxed text-white/50 hover:text-white/80 transition-all p-2 -m-2 rounded-lg cursor-pointer group"
254
+ onClick={() => scrollToElement(`ref-${cleanId}`)}
255
+ title="คลิกเพื่อกลับไปยังเนื้อหา"
256
+ >
257
+ <span className="text-[#c8860a] font-bold min-w-[24px] text-right shrink-0 group-hover:scale-110 transition-transform">{fn.id}</span>
258
+ <span className="font-light">{fn.content}</span>
259
+ </div>
260
+ );
261
+ })}
262
  </div>
263
  </div>
264
  )}
webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx CHANGED
@@ -13,9 +13,10 @@ export interface RefPopupPos {
13
  interface Props {
14
  pos: RefPopupPos | null;
15
  onClose: () => void;
 
16
  }
17
 
18
- const ReferencePopup: React.FC<Props> = ({ pos, onClose }) => {
19
  const popupRef = useRef<HTMLDivElement>(null);
20
 
21
  const [content, setContent] = React.useState<string | null>(null);
@@ -131,6 +132,20 @@ const ReferencePopup: React.FC<Props> = ({ pos, onClose }) => {
131
  <span className="text-[10px] text-white/30 uppercase tracking-tighter">AI Expanded</span>
132
  </div>
133
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  </div>
135
 
136
  {/* Tail arrow */}
 
13
  interface Props {
14
  pos: RefPopupPos | null;
15
  onClose: () => void;
16
+ onJump?: (id: string) => void;
17
  }
18
 
19
+ const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
20
  const popupRef = useRef<HTMLDivElement>(null);
21
 
22
  const [content, setContent] = React.useState<string | null>(null);
 
132
  <span className="text-[10px] text-white/30 uppercase tracking-tighter">AI Expanded</span>
133
  </div>
134
  )}
135
+
136
+ {pos.type === 'footnote' && !isLoading && onJump && (
137
+ <div className="mt-4 pt-3 border-t border-white/5">
138
+ <button
139
+ onClick={() => onJump(pos.id)}
140
+ className="w-full py-2 px-3 rounded-xl bg-white/5 hover:bg-white/10 text-[#c8860a] text-xs font-bold transition-all flex items-center justify-center gap-2 group"
141
+ >
142
+ <svg className="w-3.5 h-3.5 transform group-hover:translate-y-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
143
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
144
+ </svg>
145
+ ดูเนื้อหาเต็มด้านล่าง
146
+ </button>
147
+ </div>
148
+ )}
149
  </div>
150
 
151
  {/* Tail arrow */}