bep40 commited on
Commit
8851824
·
verified ·
1 Parent(s): 2193295

Fix AI URL input persistence and improve internet-sourced topic posts

Browse files
Files changed (1) hide show
  1. ai_ext.py +84 -30
ai_ext.py CHANGED
@@ -1,9 +1,9 @@
1
  import os, re, json, time, random, html as html_lib
2
- from urllib.parse import quote_plus
3
  from typing import Optional
4
  import requests
5
  from bs4 import BeautifulSoup
6
- from fastapi import Query, Request
7
  from fastapi.responses import HTMLResponse, JSONResponse
8
 
9
  from main import app
@@ -18,6 +18,7 @@ QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
18
  WALL_FILE = "/data/ai_wall_posts.json" if os.path.isdir("/data") else "/app/ai_wall_posts.json"
19
  HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
20
 
 
21
  def _load_ai_wall():
22
  try:
23
  if os.path.exists(WALL_FILE):
@@ -27,6 +28,7 @@ def _load_ai_wall():
27
  pass
28
  return []
29
 
 
30
  def _save_ai_wall(posts):
31
  try:
32
  os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
@@ -37,10 +39,12 @@ def _save_ai_wall(posts):
37
  except Exception:
38
  pass
39
 
 
40
  def _clean_text(s: str) -> str:
41
  s = html_lib.unescape(s or "")
42
  return re.sub(r"\s+", " ", s).strip()
43
 
 
44
  def _best_content_block(soup):
45
  best, best_score = None, 0
46
  for el in soup.find_all(["article", "main", "section", "div"]):
@@ -54,6 +58,7 @@ def _best_content_block(soup):
54
  best, best_score = el, score
55
  return best
56
 
 
57
  def scrape_any_url(url: str):
58
  r = requests.get(url, headers=HEADERS, timeout=18)
59
  r.encoding = "utf-8"
@@ -92,24 +97,61 @@ def scrape_any_url(url: str):
92
  image = images[0]
93
  return {"url": url, "title": _clean_text(title), "summary": _clean_text(summary), "text": "\n".join(paras), "image": image, "images": images}
94
 
 
95
  def pollinations_image_url(topic: str, width=1024, height=576):
96
  prompt = f"editorial illustration, topic: {topic}, modern clean cinematic news image, high quality, no text, no watermark"
97
  return f"https://image.pollinations.ai/prompt/{quote_plus(prompt)}?width={width}&height={height}&seed={random.randint(1,999999)}&nologo=true&private=true"
98
 
 
 
 
 
 
 
 
 
99
  def web_context(topic: str, limit=6):
 
 
100
  try:
101
- url = "https://duckduckgo.com/html/?q=" + quote_plus(topic + " tin tức kiến thức bối cảnh")
102
- r = requests.get(url, headers=HEADERS, timeout=10)
103
  soup = BeautifulSoup(r.text, "lxml")
104
- snippets = []
105
- for res in soup.select(".result")[:limit]:
106
- title = _clean_text(res.select_one(".result__title").get_text(" ", strip=True) if res.select_one(".result__title") else "")
107
- body = _clean_text(res.select_one(".result__snippet").get_text(" ", strip=True) if res.select_one(".result__snippet") else "")
108
- if title or body:
109
- snippets.append(f"- {title}: {body}")
110
- return "\n".join(snippets)
 
 
 
 
 
 
 
 
111
  except Exception:
112
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 1700):
115
  if not (HF_TOKEN and AsyncInferenceClient):
@@ -130,6 +172,7 @@ async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens
130
  print("[qwen fallback]", type(e).__name__, str(e)[:200])
131
  return None
132
 
 
133
  def fallback_rewrite(title, raw_text):
134
  text = _clean_text(raw_text)
135
  chunks = []
@@ -142,42 +185,48 @@ def fallback_rewrite(title, raw_text):
142
  text = text[len(part):].strip()
143
  return f"{title}\n\n" + "\n\n".join(chunks)
144
 
145
- def make_post(title, text, image, source_url, kind):
146
- return {"id": str(int(time.time()*1000)) + str(random.randint(100,999)), "title": title, "text": text, "img": image, "url": source_url, "kind": kind, "ts": int(time.time())}
 
 
147
 
148
  @app.get("/api/ai_wall")
149
  def api_ai_wall():
150
  return JSONResponse({"posts": _load_ai_wall()[:80]})
151
 
 
152
  @app.post("/api/ai/topic")
153
  async def api_ai_topic(request: Request):
154
  body = await request.json()
155
  topic = _clean_text(body.get("topic", ""))
156
  if not topic:
157
  return JSONResponse({"error":"missing topic"}, status_code=400)
158
- ctx = web_context(topic)
159
  image = pollinations_image_url(topic)
160
  prompt = f"""
161
  Hãy viết một dàn ý bài viết tiếng Việt tự nhiên cho chủ đề: {topic}
162
 
163
  Yêu cầu:
164
- - Dựa trên kiến thức thực tế và bối cảnh internet dưới đây nếu .
165
  - Có tiêu đề hấp dẫn nhưng không giật tít.
166
- - 1 đoạn mở bài ngắn.
167
- - 5-7 luận điểm chính, mỗi luận điểm có 1-2 câu giải thích.
168
  - 1 đoạn kết luận gợi ý góc nhìn cho độc giả.
 
169
  - Văn phong tự nhiên, dễ đọc.
170
 
171
- Bối cảnh tìm được trên internet:
172
- {ctx or 'Không snippet đáng tin cậy; hãy viết theo kiến thức nền phổ quát, tránh khẳng định số liệu cụ thể.'}
173
  """
174
- text = await qwen_generate(prompt, image_url=image, max_tokens=1400)
175
  if not text:
176
- text = f"{topic}\n\nMở bài: Chủ đề này đang nhận được sự quan tâm vì liên quan trực tiếp tới đời sống, công nghệ và cách con người ra quyết định.\n\nDàn ý:\nBối cảnh chung của chủ đề.\n• sao chủ đề này đáng chú ý ở hiện tại.\n• Các tác động chính tới người dùng hoặc xã hội.\n• Những lợi ích tiềm năng nếu được triển khai đúng cách.\n• Các rủi ro hoặc hiểu lầm cần tránh.\n• Gợi ý hướng tiếp cận cân bằng.\n\nKết luận: Nên nhìn chủ đề này như một xu hướng cần theo dõi liên tục, thay một hiện tượng nhất thời."
177
- post = make_post(topic, text, image, "", "topic")
 
178
  posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
179
  return JSONResponse({"post": post})
180
 
 
181
  @app.post("/api/ai/url")
182
  async def api_ai_url(request: Request):
183
  body = await request.json()
@@ -189,6 +238,8 @@ async def api_ai_url(request: Request):
189
  except Exception:
190
  return JSONResponse({"error":"Không scrape được URL"}, status_code=422)
191
  raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
 
 
192
  prompt = f"""
193
  Hãy đọc nội dung scrape dưới đây và viết lại thành một bài ngắn gọn đưa lên Tường AI VNEWS.
194
 
@@ -206,10 +257,11 @@ Nội dung gốc:
206
  text = await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1700)
207
  if not text:
208
  text = fallback_rewrite(data.get("title", "Bài viết"), raw)
209
- post = make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "url")
210
  posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
211
  return JSONResponse({"post": post})
212
 
 
213
  @app.post("/api/rewrite_share")
214
  async def api_rewrite_share(request: Request):
215
  body = await request.json()
@@ -229,6 +281,7 @@ async def api_rewrite_share(request: Request):
229
  posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
230
  return JSONResponse({"post": post})
231
 
 
232
  # Override index route to inject AI composer/wall without modifying large index.html.
233
  app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
234
 
@@ -239,13 +292,14 @@ AI_INJECT = r'''
239
  (function(){
240
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
241
  let aiWall=[];
242
- async function refreshAiWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();aiWall=j.posts||[];renderAiPanel();}catch(e){}}
243
- function renderAiPanel(){const home=document.getElementById('view-home');if(!home)return;let old=document.getElementById('ai-compose-block');if(old)old.remove();let oldw=document.getElementById('ai-wall-block');if(oldw)oldw.remove();let box=document.createElement('div');box.id='ai-compose-block';box.className='ai-compose';box.innerHTML='<div class="ai-compose-title">🤖 Tạo bài AI</div><div class="ai-row"><input id="ai-topic-input" placeholder="Nhập gợi ý chủ đề bài viết..."><button onclick="aiCreateTopic()">Tạo dàn ý</button></div><div class="ai-row"><input id="ai-url-input" placeholder="Dán URL để AI scrape và tóm tắt lên tường..."><button class="secondary" onclick="aiCreateUrl()">Chèn URL</button></div><div id="ai-compose-status" style="font-size:11px;color:#888"></div>';home.prepend(box);if(aiWall.length){let wrap=document.createElement('div');wrap.id='ai-wall-block';wrap.className='ai-wall-extra';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track">';aiWall.slice(0,20).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><button onclick="aiReadWall(${i})">Xem đầy đủ</button></div>`});h+='</div>';wrap.innerHTML=h;box.after(wrap);}}
244
- window.aiReadWall=function(i){const p=aiWall[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')"> Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
 
245
  async function postJson(url,data){const st=document.getElementById('ai-compose-status');if(st)st.textContent='AI đang xử lý...';const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(st)st.textContent='Đã đăng lên Tường AI';await refreshAiWall();return j;}
246
- window.aiCreateTopic=function(){const v=document.getElementById('ai-topic-input')?.value.trim();if(!v)return alert('Nhập chủ đề');postJson('/api/ai/topic',{topic:v}).catch(e=>alert(e.message));};
247
- window.aiCreateUrl=function(){const v=document.getElementById('ai-url-input')?.value.trim();if(!v)return alert('Dán URL');postJson('/api/ai/url',{url:v}).catch(e=>alert(e.message));};
248
- const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){await oldLoad.apply(this,arguments);setTimeout(refreshAiWall,50);};}
249
  setTimeout(refreshAiWall,1200);
250
  })();
251
  </script>
 
1
  import os, re, json, time, random, html as html_lib
2
+ from urllib.parse import quote_plus, urlparse
3
  from typing import Optional
4
  import requests
5
  from bs4 import BeautifulSoup
6
+ from fastapi import Request
7
  from fastapi.responses import HTMLResponse, JSONResponse
8
 
9
  from main import app
 
18
  WALL_FILE = "/data/ai_wall_posts.json" if os.path.isdir("/data") else "/app/ai_wall_posts.json"
19
  HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
20
 
21
+
22
  def _load_ai_wall():
23
  try:
24
  if os.path.exists(WALL_FILE):
 
28
  pass
29
  return []
30
 
31
+
32
  def _save_ai_wall(posts):
33
  try:
34
  os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
 
39
  except Exception:
40
  pass
41
 
42
+
43
  def _clean_text(s: str) -> str:
44
  s = html_lib.unescape(s or "")
45
  return re.sub(r"\s+", " ", s).strip()
46
 
47
+
48
  def _best_content_block(soup):
49
  best, best_score = None, 0
50
  for el in soup.find_all(["article", "main", "section", "div"]):
 
58
  best, best_score = el, score
59
  return best
60
 
61
+
62
  def scrape_any_url(url: str):
63
  r = requests.get(url, headers=HEADERS, timeout=18)
64
  r.encoding = "utf-8"
 
97
  image = images[0]
98
  return {"url": url, "title": _clean_text(title), "summary": _clean_text(summary), "text": "\n".join(paras), "image": image, "images": images}
99
 
100
+
101
  def pollinations_image_url(topic: str, width=1024, height=576):
102
  prompt = f"editorial illustration, topic: {topic}, modern clean cinematic news image, high quality, no text, no watermark"
103
  return f"https://image.pollinations.ai/prompt/{quote_plus(prompt)}?width={width}&height={height}&seed={random.randint(1,999999)}&nologo=true&private=true"
104
 
105
+
106
+ def _domain(u):
107
+ try:
108
+ return urlparse(u).netloc.replace("www.", "")
109
+ except Exception:
110
+ return ""
111
+
112
+
113
  def web_context(topic: str, limit=6):
114
+ """Search web and fetch short excerpts from result pages so topic posts are grounded, not generic."""
115
+ results = []
116
  try:
117
+ search_url = "https://duckduckgo.com/html/?q=" + quote_plus(topic + " tin tức phân tích bối cảnh")
118
+ r = requests.get(search_url, headers=HEADERS, timeout=12)
119
  soup = BeautifulSoup(r.text, "lxml")
120
+ for res in soup.select(".result")[:limit * 2]:
121
+ a = res.select_one(".result__a") or res.select_one("a.result__url") or res.find("a", href=True)
122
+ href = a.get("href", "") if a else ""
123
+ title = _clean_text(a.get_text(" ", strip=True) if a else "")
124
+ sn = res.select_one(".result__snippet")
125
+ snippet = _clean_text(sn.get_text(" ", strip=True) if sn else "")
126
+ if href.startswith("//duckduckgo.com/l/?"):
127
+ # DDG redirect URLs often include uddg=<encoded>; keep as fallback if parsing fails.
128
+ import urllib.parse as up
129
+ q = up.urlparse("https:" + href).query
130
+ href = up.parse_qs(q).get("uddg", [href])[0]
131
+ if href.startswith("http") and (title or snippet):
132
+ results.append({"title": title, "url": href, "snippet": snippet})
133
+ if len(results) >= limit:
134
+ break
135
  except Exception:
136
+ pass
137
+ # Fetch excerpts from top pages; this makes output much less generic.
138
+ enriched = []
139
+ for item in results[:4]:
140
+ excerpt = item.get("snippet", "")
141
+ try:
142
+ page = scrape_any_url(item["url"])
143
+ text = page.get("summary") or page.get("text", "")
144
+ text = _clean_text(text)[:650]
145
+ if text:
146
+ excerpt = text
147
+ except Exception:
148
+ pass
149
+ enriched.append({**item, "excerpt": excerpt})
150
+ if not enriched and results:
151
+ enriched = [{**x, "excerpt": x.get("snippet", "")} for x in results]
152
+ context = "\n".join([f"- {x.get('title') or _domain(x.get('url',''))} ({_domain(x.get('url',''))}): {x.get('excerpt','')}" for x in enriched if x.get("excerpt") or x.get("title")])
153
+ return context, enriched
154
+
155
 
156
  async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 1700):
157
  if not (HF_TOKEN and AsyncInferenceClient):
 
172
  print("[qwen fallback]", type(e).__name__, str(e)[:200])
173
  return None
174
 
175
+
176
  def fallback_rewrite(title, raw_text):
177
  text = _clean_text(raw_text)
178
  chunks = []
 
185
  text = text[len(part):].strip()
186
  return f"{title}\n\n" + "\n\n".join(chunks)
187
 
188
+
189
+ def make_post(title, text, image, source_url, kind, sources=None):
190
+ return {"id": str(int(time.time()*1000)) + str(random.randint(100,999)), "title": title, "text": text, "img": image, "url": source_url, "kind": kind, "sources": sources or [], "ts": int(time.time())}
191
+
192
 
193
  @app.get("/api/ai_wall")
194
  def api_ai_wall():
195
  return JSONResponse({"posts": _load_ai_wall()[:80]})
196
 
197
+
198
  @app.post("/api/ai/topic")
199
  async def api_ai_topic(request: Request):
200
  body = await request.json()
201
  topic = _clean_text(body.get("topic", ""))
202
  if not topic:
203
  return JSONResponse({"error":"missing topic"}, status_code=400)
204
+ ctx, sources = web_context(topic)
205
  image = pollinations_image_url(topic)
206
  prompt = f"""
207
  Hãy viết một dàn ý bài viết tiếng Việt tự nhiên cho chủ đề: {topic}
208
 
209
  Yêu cầu:
210
+ - Phải dựa trên các nguồn/bối cảnh internet bên dưới; nếu nguồn không đủ, nói rõ góc nhìn là tổng quan.
211
  - Có tiêu đề hấp dẫn nhưng không giật tít.
212
+ - 1 đoạn mở bài ngắn nêu bối cảnh thực tế.
213
+ - 5-7 luận điểm chính, mỗi luận điểm có 1-2 câu giải thích cụ thể.
214
  - 1 đoạn kết luận gợi ý góc nhìn cho độc giả.
215
+ - Cuối bài thêm mục "Nguồn tham khảo" liệt kê 2-4 nguồn theo tên website.
216
  - Văn phong tự nhiên, dễ đọc.
217
 
218
+ Bối cảnh/nguồn tìm được trên internet:
219
+ {ctx or 'Không lấy được nguồn cụ thể; hãy viết tổng quan, tránh khẳng định số liệu hoặc sự kiện cụ thể.'}
220
  """
221
+ text = await qwen_generate(prompt, image_url=image, max_tokens=1500)
222
  if not text:
223
+ source_lines = "\n".join([f"{s.get('title') or _domain(s.get('url',''))} ({_domain(s.get('url',''))})" for s in sources[:4]])
224
+ text = f"{topic}\n\nMở bài: Chủ đề này có nhiều khía cạnh cần xem xét trong bối cảnh hiện nay.\n\nDàn ý:\n• Bối cảnh chung: {sources[0]['excerpt'][:220] if sources else 'Cần nhìn chủ đề trong mối liên hệ với đời sống, công nghệ và xã hội.'}\n• Vì sao đáng chú ý: chủ đề có thể tác động tới cách người dùng tiếp nhận thông tin hoặc ra quyết định.\n• Các tác động chính: cần xem xét cả lợi ích, rủi ro và nhóm đối tượng chịu ảnh hưởng.\n• Góc nhìn thực tiễn: nên đối chiếu nhiều nguồn thay vì dựa vào một nhận định đơn lẻ.\n• Hướng khai thác bài viết: đặt câu hỏi điều gì đang thay đổi, ai được lợi, ai gặp rủi ro.\n\nKết luận: Đây là chủ đề nên được trình bày cân bằng, có dẫn nguồn và tránh khẳng định quá mức.\n\nNguồn tham khảo:\n{source_lines}"
225
+ post = make_post(topic, text, image, "", "topic", sources=sources[:5])
226
  posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
227
  return JSONResponse({"post": post})
228
 
229
+
230
  @app.post("/api/ai/url")
231
  async def api_ai_url(request: Request):
232
  body = await request.json()
 
238
  except Exception:
239
  return JSONResponse({"error":"Không scrape được URL"}, status_code=422)
240
  raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
241
+ if len(raw) < 80:
242
+ return JSONResponse({"error":"URL không có đủ nội dung để tóm tắt"}, status_code=422)
243
  prompt = f"""
244
  Hãy đọc nội dung scrape dưới đây và viết lại thành một bài ngắn gọn đưa lên Tường AI VNEWS.
245
 
 
257
  text = await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1700)
258
  if not text:
259
  text = fallback_rewrite(data.get("title", "Bài viết"), raw)
260
+ post = make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "url", sources=[{"title": data.get("title"), "url": url, "excerpt": raw[:500]}])
261
  posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
262
  return JSONResponse({"post": post})
263
 
264
+
265
  @app.post("/api/rewrite_share")
266
  async def api_rewrite_share(request: Request):
267
  body = await request.json()
 
281
  posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
282
  return JSONResponse({"post": post})
283
 
284
+
285
  # Override index route to inject AI composer/wall without modifying large index.html.
286
  app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
287
 
 
292
  (function(){
293
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
294
  let aiWall=[];
295
+ function currentInputs(){return {topic:document.getElementById('ai-topic-input')?.value||'',url:document.getElementById('ai-url-input')?.value||''};}
296
+ async function refreshAiWall(){try{const keep=currentInputs();const r=await fetch('/api/ai_wall');const j=await r.json();aiWall=j.posts||[];renderAiPanel(keep);}catch(e){}}
297
+ function renderAiPanel(keep={topic:'',url:''}){const home=document.getElementById('view-home');if(!home)return;let old=document.getElementById('ai-compose-block');if(old){const k=currentInputs();keep.topic=keep.topic||k.topic;keep.url=keep.url||k.url;old.remove();}let oldw=document.getElementById('ai-wall-block');if(oldw)oldw.remove();let box=document.createElement('div');box.id='ai-compose-block';box.className='ai-compose';box.innerHTML='<div class="ai-compose-title">🤖 Tạo bài AI</div><div class="ai-row"><input id="ai-topic-input" placeholder="Nhập gợi ý chủ đề bài viết..."><button onclick="aiCreateTopic()">Tạo dàn ý</button></div><div class="ai-row"><input id="ai-url-input" placeholder="Dán URL để AI scrape và tóm tắt lên tường..."><button class="secondary" onclick="aiCreateUrl()">Chèn URL</button></div><div id="ai-compose-status" style="font-size:11px;color:#888"></div>';home.prepend(box);document.getElementById('ai-topic-input').value=keep.topic||'';document.getElementById('ai-url-input').value=keep.url||'';if(aiWall.length){let wrap=document.createElement('div');wrap.id='ai-wall-block';wrap.className='ai-wall-extra';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track">';aiWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><button onclick="aiReadWall(${i})">Xem đầy đủ</button></div>`});h+='</div>';wrap.innerHTML=h;box.after(wrap);}}
298
+ window.aiReadWall=function(i){const p=aiWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
299
  async function postJson(url,data){const st=document.getElementById('ai-compose-status');if(st)st.textContent='AI đang xử lý...';const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(st)st.textContent='Đã đăng lên Tường AI';await refreshAiWall();return j;}
300
+ window.aiCreateTopic=function(){const v=(document.getElementById('ai-topic-input')?.value||'').trim();if(!v)return alert('Nhập chủ đề');postJson('/api/ai/topic',{topic:v}).catch(e=>alert(e.message));};
301
+ window.aiCreateUrl=function(){const input=document.getElementById('ai-url-input');const v=(input?.value||'').trim();if(!v)return alert('Dán URL trước');if(!/^https?:\/\//i.test(v))return alert('URL cần bắt đầu bằng http:// hoặc https://');postJson('/api/ai/url',{url:v}).catch(e=>alert(e.message));};
302
+ const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){const keep=currentInputs();await oldLoad.apply(this,arguments);setTimeout(()=>refreshAiWall(),50);};}
303
  setTimeout(refreshAiWall,1200);
304
  })();
305
  </script>