bep40 commited on
Commit
e8bc7d7
·
verified ·
1 Parent(s): decdd25

Fix: scrape URLs on rewrite, in-app links, natural AI, persistent check"

Browse files
Files changed (1) hide show
  1. ai_runtime_patch_fast.py +126 -142
ai_runtime_patch_fast.py CHANGED
@@ -1,207 +1,196 @@
1
- """Patch: persistent data, single ask AI box, working rewrite, Qwen AI answers."""
2
  import re, threading, time, json, os, asyncio
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
  import html as html_lib
 
6
 
7
  def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
 
 
 
8
 
9
- # === PERSISTENT STORAGE ===
10
- # HF Spaces with persistent storage mount /data. Without it, use /app/data (lost on rebuild).
11
- # To survive rebuild: Space Settings → enable Persistent Storage.
12
  DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
13
  os.makedirs(DATA_DIR,exist_ok=True)
14
  SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
15
- # AI wall file is managed by f5.base (ai_ext.py) which uses /data/ai_wall_posts.json if /data exists.
16
- # We just need to ensure the directory exists.
17
  TTL_24H=86400
 
18
 
19
  def _load_json(path,default):
20
  try:
21
  if os.path.exists(path):
22
  with open(path,'r',encoding='utf-8') as f:return json.load(f)
23
- except Exception:pass
24
  return default
25
  def _save_json(path,data):
26
  try:
27
  os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
28
  with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
29
  os.replace(tmp,path)
30
- except Exception:pass
31
 
32
  def _cleanup_old_posts():
33
  now=int(time.time());posts=f5.base._load_ai_wall()
34
  fresh=[p for p in posts if now-int(p.get('ts') or 0)<TTL_24H]
35
  if len(fresh)<len(posts):f5.base._save_ai_wall(fresh)
36
 
37
- # ===== BACKGROUND PREFETCH =====
38
- _bg_home_cache={"t":0,"d":[]};_bg_shorts_cache={"t":0,"d":[]};_bg_loading=False
 
 
 
 
 
 
 
39
  def _bg_refresh():
40
- global _bg_loading
41
- if _bg_loading:return
42
- _bg_loading=True
43
  try:
44
  if hasattr(f6,'_fast_homepage'):
45
- data=f6._fast_homepage()
46
- if data:_bg_home_cache.update({"t":time.time(),"d":data})
47
  raw=[]
48
  for h in f6.YOUTUBE_HANDLES:raw.extend(f6._yt_ytdlp(h,20) or f6._yt_html(h,20))
49
  raw.extend(f6._fallback_shorts());seen=set();out=[]
50
  for v in raw:
51
  vid=v.get('id') or ''
52
  if vid and vid not in seen:seen.add(vid);out.append(v)
53
- if out:_bg_shorts_cache.update({"t":time.time(),"d":out[:40]})
54
  _cleanup_old_posts()
55
- except Exception:pass
56
- finally:_bg_loading=False
57
-
58
  @app.on_event("startup")
59
- async def startup_prefetch():threading.Thread(target=_bg_refresh,daemon=True).start()
60
- def _periodic():
61
  while True:time.sleep(600);_bg_refresh()
62
- threading.Thread(target=_periodic,daemon=True).start()
63
 
64
- # ===== OVERRIDE ENDPOINTS =====
65
  app.router.routes=[r for r in app.router.routes if not (
66
- (getattr(r,'path',None)=='/api/homepage' and 'GET' in getattr(r,'methods',set())) or
67
- (getattr(r,'path',None)=='/api/shorts' and 'GET' in getattr(r,'methods',set())) or
68
- (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set())) or
69
- (getattr(r,'path',None)=='/api/short/comments' and 'GET' in getattr(r,'methods',set())) or
70
- (getattr(r,'path',None)=='/api/short/comment' and 'POST' in getattr(r,'methods',set())) or
71
- (getattr(r,'path',None)=='/api/article/ask' and 'POST' in getattr(r,'methods',set())) or
72
- (getattr(r,'path',None)=='/api/topic/rewrite' and 'POST' in getattr(r,'methods',set())) or
73
- (getattr(r,'path',None)=='/api/rewrite_share' and 'POST' in getattr(r,'methods',set())) or
74
- (getattr(r,'path',None)=='/api/url_wall' and 'POST' in getattr(r,'methods',set())) or
75
- (getattr(r,'path',None)=='/api/ai_wall' and 'GET' in getattr(r,'methods',set())) or
76
- (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
77
  )]
78
 
79
  @app.get('/api/homepage')
80
- def api_homepage_instant():
81
  now=time.time()
82
- if _bg_home_cache['d']:
83
- if now-_bg_home_cache['t']>300:threading.Thread(target=_bg_refresh,daemon=True).start()
84
- return JSONResponse(_bg_home_cache['d'])
85
  if hasattr(f6,'_fast_homepage'):
86
- data=f6._fast_homepage()
87
- if data:_bg_home_cache.update({"t":now,"d":data});return JSONResponse(data)
88
  return JSONResponse([])
89
 
90
  @app.get('/api/shorts')
91
- def api_shorts_instant(refresh:int=Query(default=0)):
92
  now=time.time()
93
- if _bg_shorts_cache['d'] and (not refresh or now-_bg_shorts_cache['t']<120):
94
- if now-_bg_shorts_cache['t']>600:threading.Thread(target=_bg_refresh,daemon=True).start()
95
- return JSONResponse(_bg_shorts_cache['d'])
96
  if hasattr(f6,'api_shorts_final6'):return f6.api_shorts_final6(refresh=refresh)
97
  return JSONResponse([])
98
 
99
  @app.get('/api/ai_wall')
100
- def api_ai_wall_with_ttl():
101
  now=int(time.time());posts=f5.base._load_ai_wall()
102
  fresh=[p for p in posts if now-int(p.get('ts') or 0)<TTL_24H]
103
- return JSONResponse({'posts':fresh})
 
 
 
104
 
105
- # ===== SHORT COMMENTS =====
106
  @app.get('/api/short/comments')
107
- def get_short_comments(id:str=Query(...)):
108
- db=_load_json(SHORT_COMMENTS_FILE,{});return JSONResponse({'comments':db.get(id,[])})
109
 
110
  @app.post('/api/short/comment')
111
- async def post_short_comment(request:Request):
112
  body=await request.json();vid=str(body.get('id','')).strip();text=clean(body.get('text',''))
113
- if not vid or not text:return JSONResponse({'error':'missing id or text'},status_code=400)
114
- db=_load_json(SHORT_COMMENTS_FILE,{});comments=db.get(vid,[]);comments.insert(0,{'text':text[:300],'ts':int(time.time())})
115
- db[vid]=comments[:100];_save_json(SHORT_COMMENTS_FILE,db);return JSONResponse({'comments':db[vid]})
116
 
117
- # ===== ASK AI — uses Qwen model =====
118
  @app.post('/api/article/ask')
119
- async def article_ask(request:Request):
120
- body=await request.json();url=clean(body.get('url',''));question=clean(body.get('question',''));context=clean(body.get('context',''))
121
- if not question:return JSONResponse({'error':'missing question'},status_code=400)
122
  title='';raw=''
123
  if url:
124
- try:
125
- data=f5.base.scrape_any_url(url) if hasattr(f5.base,'scrape_any_url') else None
126
- if data:title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
127
- except Exception:pass
128
- if not raw and context:raw=context[:12000]
129
- if not raw:raw=question
130
- prompt=f"""Bạn là trợ lý thông minh VNEWS. Người dùng đang đọc bài viết/xem video và hỏi bạn.
131
-
132
- Nội dung bài:
133
- ---
134
- Tiêu đề: {title}
135
- {raw[:10000]}
136
- ---
137
 
138
- Câu hỏi: {question}
139
 
140
- Trả lời tự nhiên, thân thiện bằng tiếng Việt:
141
- - Dựa vào nội dung bài làm cơ sở chính
142
- - Giải thích dễ hiểu như đang trò chuyện
143
- - Nếu câu hỏi mở rộng hơn bài → bổ sung kiến thức chung, nói rõ "theo hiểu biết chung..."
144
  """
145
  ans=await f5.base.qwen_generate(prompt,max_tokens=1200)
146
- if not ans:ans='AI chưa trả lời được. Kiểm tra HF_TOKEN trong Space Settings → Secrets.'
147
  return JSONResponse({'answer':ans,'title':title})
148
 
149
- # ===== REWRITE — works for both regular articles and topic posts =====
150
  @app.post('/api/rewrite_share')
151
  @app.post('/api/url_wall')
152
- async def rewrite_url(request:Request):
153
  body=await request.json();url=clean(body.get('url',''))
154
  if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
155
- title='';raw=''
156
- try:
157
- data=f5.base.scrape_any_url(url) if hasattr(f5.base,'scrape_any_url') else None
158
- if data:title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
159
- except Exception as e:
160
- return JSONResponse({'error':'Không đọc được URL: '+str(e)[:150]},status_code=422)
161
- if len(raw)<100:return JSONResponse({'error':'URL không có đủ nội dung'},status_code=422)
162
- img=(data.get('image') or data.get('og_image') or '') if data else ''
163
- prompt=f"""Tóm tắt bài viết sau đăng lên Tường AI VNEWS.
164
 
165
  Tiêu đề: {title}
166
  Nội dung:
167
  {raw[:14000]}
168
 
169
- Yêu cầu:
170
- - Tóm tắt ngắn gọn, cụ thể, 4-6 ý chính.
171
- - Không lặp ý, không bịa.
172
- - Cuối bài ghi nguồn.
173
  """
174
  text=None
175
  try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img or None,max_tokens=1000),timeout=30)
176
- except Exception:text=None
177
- if not text or len(text)<100:
178
- text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {url}"
179
- from urllib.parse import urlparse
180
- via=urlparse(url).netloc.replace('www.','')
181
- post=f5.base.make_post(title or 'Bài viết',text,img,url,'rewrite',sources=[{'title':title,'url':url,'via':via}])
182
  posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
183
  return JSONResponse({'post':post})
184
 
185
  @app.post('/api/topic/rewrite')
186
- async def topic_rewrite(request:Request):
 
187
  body=await request.json();post_id=str(body.get('post_id','')).strip()
188
  if not post_id:return JSONResponse({'error':'missing post_id'},status_code=400)
189
  posts=f5.base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==post_id),None)
190
  if not p:return JSONResponse({'error':'Không tìm thấy bài.'},status_code=404)
191
- all_content=(p.get('text') or '')
192
- for s in (p.get('source_details') or []):all_content+='\n\n'+s.get('content','')
 
 
 
 
 
 
 
 
 
 
 
193
  title=p.get('title') or 'Bài viết'
194
- prompt=f"""Viết lại nội dung sau thành bản tóm tắt mới, hấp dẫn, đăng Tường AI.
195
 
196
- Tiêu đề gốc: {title}
197
- Nội dung:
 
198
  {all_content[:16000]}
199
 
200
- Yêu cầu: tiêu đề mới + 3-5 ý chính + nguồn tham khảo.
201
  """
202
  text=None
203
- try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=p.get('img'),max_tokens=1200),timeout=30)
204
- except Exception:text=None
205
  if not text or len(text)<150:text=f"Tóm tắt: {title}\n\n{all_content[:1500]}\n\nNguồn: VNEWS AI"
206
  new_post=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]))
207
  new_post['images']=p.get('images',[])
@@ -210,7 +199,7 @@ Yêu cầu: tiêu đề mới + 3-5 ý chính + nguồn tham khảo.
210
 
211
  # ===== TOPIC POST =====
212
  @app.post('/api/topic_post')
213
- async def topic_post_focused(request:Request):
214
  body=await request.json();topic=clean(body.get('topic',''))
215
  if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
216
  img=f6._topic_image(topic)
@@ -219,16 +208,16 @@ async def topic_post_focused(request:Request):
219
  details=f6._extract_source_details_from_context(context,sources) if hasattr(f6,'_extract_source_details_from_context') else []
220
  if not context or not sources:return JSONResponse({'error':'Không tìm được nội dung. Thử chủ đề cụ thể hơn.'},status_code=422)
221
  source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)]) if details else context[:18000]
222
- prompt=f"""Viết MỘT BÀI VIẾT tiếng Việt VỀ ĐÚNG CHỦ ĐỀ: "{topic}"
223
 
224
  NGUỒN:
225
  {source_brief[:18000]}
226
 
227
- QUY TẮC: CHỈ viết về "{topic}". Tiêu đề chứa "{topic}". 5-8 đoạn. Cuối bài có nguồn.
228
  """
229
  text=None
230
  try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
231
- except Exception:text=None
232
  if not text or len(text)<300:
233
  bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (details or [])[:6]])
234
  vias=', '.join(sorted({d.get('via','') for d in (details or []) if d.get('via')}))
@@ -238,18 +227,21 @@ QUY TẮC: CHỈ viết về "{topic}". Tiêu đề chứa "{topic}". 5-8 đoạ
238
  posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
239
  return JSONResponse({'post':post})
240
 
241
- # ===== FRONTEND — single ask box, working rewrite =====
242
  PATCH_INJECT=r'''
243
- <style>.short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0}.source-detail-title{font-size:12px;font-weight:700;color:#eee}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:160px;overflow:auto}.source-detail-item a{color:#5cb87a;font-size:11px;cursor:pointer}.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}</style>
244
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
245
  <script>
246
  (function(){
247
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
248
 
 
 
 
249
  function renderSourceDetails(post,container){
250
  let details=post.source_details||[];if(!details.length)return;
251
  let box=document.createElement('div');box.className='source-detail-box';
252
- box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài viết nguồn</h3>'+details.map((s,i)=>`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,500))}</div>${s.url?`<a onclick="event.preventDefault();if(typeof readArticle==='function')readArticle('${esc(s.url)}')">📖 Đọc trên VNEWS</a>`:''}</div>`).join('');
253
  container.appendChild(box);
254
  details.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
255
 
@@ -258,55 +250,47 @@ let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]})))
258
  let p=wall[i];if(!p)return;showView('view-article');
259
  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}">`:''}`;
260
  h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;
261
- h+=`<div class="article-actions"><button class="primary" onclick="rewriteTopicPost('${esc(p.id)}')">🤖 Tóm tắt AI đăng tường</button><button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤 Chia sẻ</button></div>`;
262
- h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung này..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;
263
  document.getElementById('view-article').innerHTML=h;
264
  let art=document.querySelector('.article-view');if(art&&p.source_details)renderSourceDetails(p,art);
265
  window.scrollTo(0,0);}
266
  window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
267
 
268
  window.rewriteTopicPost=async function(postId){
269
- let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tóm tắt...';}
270
- try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:postId})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã tóm tắt và đăng lên Tường AI!');}catch(e){alert('Lỗi: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🤖 Tóm tắt AI đăng tường';}}};
271
 
272
  window.askAIWall=async function(i){
273
  let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');
274
- document.getElementById('article-ai-ans').textContent='Đang hỏi AI...';
275
  let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];
276
- let p=wall[i]||{};let context=(p.text||'');for(let s of (p.source_details||[]))context+='\n'+s.content;
277
  try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:context.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
278
 
279
- // Short comments
280
- window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));let cmts=j.comments||[];panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${cmts.map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
281
- window.submitShortCmt=async function(id){let text=document.getElementById('cmt-text')?.value.trim();if(!text)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';}
282
 
283
- function patchShortButtons(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let right=sl.querySelector('.tiktok-right,.short-action-panel');if(!right)return;if(right.querySelector('[data-cmt]'))return;let btn=document.createElement('button');btn.className='short-action-btn';btn.setAttribute('data-cmt','1');btn.innerHTML='<div class="ico" style="width:42px;height:42px;border-radius:50%;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-size:20px">💬</div>';btn.onclick=function(e){e.stopPropagation();openShortComments(id);};right.appendChild(btn);});}
284
-
285
- // Single rewrite + ask for regular articles (only ONE of each)
286
- function patchArticleView(){let art=document.querySelector('#view-article .article-view');if(!art)return;
287
- // Remove ALL duplicate ask boxes and rewrite buttons injected by old layers
288
- let asks=art.querySelectorAll('.article-ai-ask');if(asks.length>1)for(let i=1;i<asks.length;i++)asks[i].remove();
289
- let rws=art.querySelectorAll('[data-rewrite]');if(rws.length>1)for(let i=1;i<rws.length;i++)rws[i].remove();
290
- // Add rewrite if missing
291
- if(!art.querySelector('[data-rewrite]')){let actions=art.querySelector('.article-actions');if(actions){let btn=document.createElement('button');btn.className='primary';btn.setAttribute('data-rewrite','1');btn.textContent='🤖 Tóm tắt AI đăng tường';btn.onclick=function(){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){alert('Không có URL bài viết');return;}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json()).then(j=>{if(j.post)alert('Đã tóm tắt và đăng lên Tường AI!');else alert(j.error||'Lỗi')}).catch(e=>alert(e.message))};actions.insertBefore(btn,actions.firstChild);}}
292
- // Add ask if missing
293
  if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi về bài viết..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
294
- window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let ans=document.getElementById('article-ai-answer');ans.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();ans.textContent=j.answer||'Không trả lời được';}catch(e){ans.textContent='Lỗi: '+e.message}}
295
-
296
- // Override rewriteCurrentArticle used by old layers
297
- window.rewriteCurrentArticle=function(){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){alert('Không có URL bài viết để rewrite');return;}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json()).then(j=>{if(j.post)alert('Đã tóm tắt và đăng lên Tường AI!');else alert(j.error||'Lỗi')}).catch(e=>alert(e.message))};
298
-
299
- let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticleView,600);return ret;}}
300
 
301
- let _hotLoaded=false;function deferHot(){if(_hotLoaded)return;_hotLoaded=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
302
- if(document.readyState==='complete')deferHot();else window.addEventListener('load',deferHot);
303
- setInterval(()=>{patchArticleView();patchShortButtons();},1500);
 
304
  })();
305
  </script>
306
  '''
307
 
308
  @app.get('/')
309
- async def index_patched():
310
  html=f5.f4.f3.f2.f1._load_index_html()
311
  body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
312
  body+=getattr(f6,'FINAL6_INJECT','')
 
1
+ """Patch: scrape source URLs on rewrite, in-app article links, natural AI chat, persistent data."""
2
  import re, threading, time, json, os, asyncio
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
  import html as html_lib
6
+ from urllib.parse import urlparse
7
 
8
  def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
9
+ def _domain(u):
10
+ try:return urlparse(u or '').netloc.replace('www.','')
11
+ except:return ''
12
 
 
 
 
13
  DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
14
  os.makedirs(DATA_DIR,exist_ok=True)
15
  SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
 
 
16
  TTL_24H=86400
17
+ HAS_PERSISTENT=os.path.isdir('/data')
18
 
19
  def _load_json(path,default):
20
  try:
21
  if os.path.exists(path):
22
  with open(path,'r',encoding='utf-8') as f:return json.load(f)
23
+ except:pass
24
  return default
25
  def _save_json(path,data):
26
  try:
27
  os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
28
  with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
29
  os.replace(tmp,path)
30
+ except:pass
31
 
32
  def _cleanup_old_posts():
33
  now=int(time.time());posts=f5.base._load_ai_wall()
34
  fresh=[p for p in posts if now-int(p.get('ts') or 0)<TTL_24H]
35
  if len(fresh)<len(posts):f5.base._save_ai_wall(fresh)
36
 
37
+ def _scrape_url_text(url,max_chars=8000):
38
+ """Scrape full article text from a URL."""
39
+ try:
40
+ data=f5.base.scrape_any_url(url)
41
+ return (data.get('title',''),((data.get('summary','')+'\n'+data.get('text','')).strip())[:max_chars],data.get('image') or data.get('og_image') or '')
42
+ except:return ('','','')
43
+
44
+ # ===== BACKGROUND =====
45
+ _bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
46
  def _bg_refresh():
47
+ global _bg_lock
48
+ if _bg_lock:return
49
+ _bg_lock=True
50
  try:
51
  if hasattr(f6,'_fast_homepage'):
52
+ d=f6._fast_homepage()
53
+ if d:_bg_home.update({"t":time.time(),"d":d})
54
  raw=[]
55
  for h in f6.YOUTUBE_HANDLES:raw.extend(f6._yt_ytdlp(h,20) or f6._yt_html(h,20))
56
  raw.extend(f6._fallback_shorts());seen=set();out=[]
57
  for v in raw:
58
  vid=v.get('id') or ''
59
  if vid and vid not in seen:seen.add(vid);out.append(v)
60
+ if out:_bg_shorts.update({"t":time.time(),"d":out[:40]})
61
  _cleanup_old_posts()
62
+ except:pass
63
+ finally:_bg_lock=False
 
64
  @app.on_event("startup")
65
+ async def _startup():threading.Thread(target=_bg_refresh,daemon=True).start()
66
+ def _loop():
67
  while True:time.sleep(600);_bg_refresh()
68
+ threading.Thread(target=_loop,daemon=True).start()
69
 
70
+ # ===== ENDPOINTS =====
71
  app.router.routes=[r for r in app.router.routes if not (
72
+ (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))
 
 
 
 
 
 
 
 
 
 
73
  )]
74
 
75
  @app.get('/api/homepage')
76
+ def _homepage():
77
  now=time.time()
78
+ if _bg_home['d']:
79
+ if now-_bg_home['t']>300:threading.Thread(target=_bg_refresh,daemon=True).start()
80
+ return JSONResponse(_bg_home['d'])
81
  if hasattr(f6,'_fast_homepage'):
82
+ d=f6._fast_homepage()
83
+ if d:_bg_home.update({"t":now,"d":d});return JSONResponse(d)
84
  return JSONResponse([])
85
 
86
  @app.get('/api/shorts')
87
+ def _shorts(refresh:int=Query(default=0)):
88
  now=time.time()
89
+ if _bg_shorts['d'] and (not refresh or now-_bg_shorts['t']<120):
90
+ if now-_bg_shorts['t']>600:threading.Thread(target=_bg_refresh,daemon=True).start()
91
+ return JSONResponse(_bg_shorts['d'])
92
  if hasattr(f6,'api_shorts_final6'):return f6.api_shorts_final6(refresh=refresh)
93
  return JSONResponse([])
94
 
95
  @app.get('/api/ai_wall')
96
+ def _ai_wall():
97
  now=int(time.time());posts=f5.base._load_ai_wall()
98
  fresh=[p for p in posts if now-int(p.get('ts') or 0)<TTL_24H]
99
+ return JSONResponse({'posts':fresh,'persistent':HAS_PERSISTENT})
100
+
101
+ @app.get('/api/storage_status')
102
+ def _storage():return JSONResponse({'persistent':HAS_PERSISTENT,'data_dir':DATA_DIR})
103
 
 
104
  @app.get('/api/short/comments')
105
+ def _get_cmts(id:str=Query(...)):return JSONResponse({'comments':_load_json(SHORT_COMMENTS_FILE,{}).get(id,[])})
 
106
 
107
  @app.post('/api/short/comment')
108
+ async def _post_cmt(request:Request):
109
  body=await request.json();vid=str(body.get('id','')).strip();text=clean(body.get('text',''))
110
+ if not vid or not text:return JSONResponse({'error':'missing'},status_code=400)
111
+ db=_load_json(SHORT_COMMENTS_FILE,{});c=db.get(vid,[]);c.insert(0,{'text':text[:300],'ts':int(time.time())})
112
+ db[vid]=c[:100];_save_json(SHORT_COMMENTS_FILE,db);return JSONResponse({'comments':db[vid]})
113
 
114
+ # ===== ASK AI — truly conversational =====
115
  @app.post('/api/article/ask')
116
+ async def _ask(request:Request):
117
+ body=await request.json();url=clean(body.get('url',''));q=clean(body.get('question',''));ctx=clean(body.get('context',''))
118
+ if not q:return JSONResponse({'error':'missing question'},status_code=400)
119
  title='';raw=''
120
  if url:
121
+ title,raw,_=_scrape_url_text(url,10000)
122
+ if not raw and ctx:raw=ctx[:12000]
123
+ prompt=f"""Bạn tên là VNEWS AI, một người bạn thông minh và am hiểu. Người dùng đang đọc bài viết/xem video và hỏi bạn một câu.
124
+
125
+ Đây nội dung họ đang xem:
126
+ "{title}"
127
+ {raw[:9000]}
 
 
 
 
 
 
128
 
129
+ Họ hỏi: "{q}"
130
 
131
+ Hãy trả lời như một người bạn đang giải thích cho người khác nghe — tự nhiên, dễ hiểu, có chiều sâu. Đừng nói kiểu robot hay liệt kê khô khan. Nếu bài không nói đến phần họ hỏi, bạn vẫn có thể chia sẻ kiến thức của mình nhưng nói rõ "theo mình biết thêm thì..." Trả lời bằng tiếng Việt.
 
 
 
132
  """
133
  ans=await f5.base.qwen_generate(prompt,max_tokens=1200)
134
+ if not ans:ans='Mình chưa trả lời được lúc này. Bạn thử hỏi lại nhé!'
135
  return JSONResponse({'answer':ans,'title':title})
136
 
137
+ # ===== REWRITE — scrape source URLs for full content =====
138
  @app.post('/api/rewrite_share')
139
  @app.post('/api/url_wall')
140
+ async def _rewrite_url(request:Request):
141
  body=await request.json();url=clean(body.get('url',''))
142
  if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
143
+ title,raw,img=_scrape_url_text(url,14000)
144
+ if len(raw)<100:return JSONResponse({'error':'Không đọc được bài viết từ URL này'},status_code=422)
145
+ prompt=f"""Tóm tắt bài viết sau để đăng Tường AI:
 
 
 
 
 
 
146
 
147
  Tiêu đề: {title}
148
  Nội dung:
149
  {raw[:14000]}
150
 
151
+ Yêu cầu: tóm tắt 4-6 ý chính, tự nhiên, không bịa. Cuối ghi nguồn.
 
 
 
152
  """
153
  text=None
154
  try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img or None,max_tokens=1000),timeout=30)
155
+ except:pass
156
+ if not text or len(text)<100:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
157
+ post=f5.base.make_post(title or 'Bài viết',text,img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
 
 
 
158
  posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
159
  return JSONResponse({'post':post})
160
 
161
  @app.post('/api/topic/rewrite')
162
+ async def _topic_rewrite(request:Request):
163
+ """Rewrite topic post by RE-SCRAPING all source URLs for fresh full content."""
164
  body=await request.json();post_id=str(body.get('post_id','')).strip()
165
  if not post_id:return JSONResponse({'error':'missing post_id'},status_code=400)
166
  posts=f5.base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==post_id),None)
167
  if not p:return JSONResponse({'error':'Không tìm thấy bài.'},status_code=404)
168
+ # Collect URLs from source_details and sources
169
+ urls=[]
170
+ for s in (p.get('source_details') or []):
171
+ if s.get('url'):urls.append(s['url'])
172
+ for s in (p.get('sources') or []):
173
+ if s.get('url') and s['url'] not in urls:urls.append(s['url'])
174
+ # Scrape all source URLs for FULL content
175
+ scraped_parts=[]
176
+ for u in urls[:5]:
177
+ t,raw,_=_scrape_url_text(u,6000)
178
+ if raw and len(raw)>200:
179
+ scraped_parts.append(f"NGUỒN: {_domain(u)}\nTIÊU ĐỀ: {t}\n{raw}")
180
+ all_content='\n\n---\n\n'.join(scraped_parts) if scraped_parts else (p.get('text') or '')
181
  title=p.get('title') or 'Bài viết'
182
+ prompt=f"""Viết lại thành bản tóm tắt mới hấp dẫn từ các nguồn sau:
183
 
184
+ Chủ đề gốc: {title}
185
+
186
+ Nội dung các nguồn đã scrape:
187
  {all_content[:16000]}
188
 
189
+ Yêu cầu: tiêu đề mới hấp dẫn + 4-6 ý chính tổng hợp + nguồn tham khảo cuối bài.
190
  """
191
  text=None
192
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=p.get('img'),max_tokens=1200),timeout=35)
193
+ except:pass
194
  if not text or len(text)<150:text=f"Tóm tắt: {title}\n\n{all_content[:1500]}\n\nNguồn: VNEWS AI"
195
  new_post=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]))
196
  new_post['images']=p.get('images',[])
 
199
 
200
  # ===== TOPIC POST =====
201
  @app.post('/api/topic_post')
202
+ async def _topic(request:Request):
203
  body=await request.json();topic=clean(body.get('topic',''))
204
  if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
205
  img=f6._topic_image(topic)
 
208
  details=f6._extract_source_details_from_context(context,sources) if hasattr(f6,'_extract_source_details_from_context') else []
209
  if not context or not sources:return JSONResponse({'error':'Không tìm được nội dung. Thử chủ đề cụ thể hơn.'},status_code=422)
210
  source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)]) if details else context[:18000]
211
+ prompt=f"""Viết bài tiếng Việt VỀ ĐÚNG CHỦ ĐỀ: "{topic}"
212
 
213
  NGUỒN:
214
  {source_brief[:18000]}
215
 
216
+ CHỈ viết về "{topic}". Tiêu đề chứa "{topic}". 5-8 đoạn. Cuối có nguồn.
217
  """
218
  text=None
219
  try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
220
+ except:pass
221
  if not text or len(text)<300:
222
  bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (details or [])[:6]])
223
  vias=', '.join(sorted({d.get('via','') for d in (details or []) if d.get('via')}))
 
227
  posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
228
  return JSONResponse({'post':post})
229
 
230
+ # ===== FRONTEND =====
231
  PATCH_INJECT=r'''
232
+ <style>.short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}.source-detail-title{font-size:12px;font-weight:700;color:#eee}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:140px;overflow:auto}.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}.source-read-btn{color:#5cb87a;font-size:11px;margin-top:4px;display:inline-block}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}</style>
233
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
234
  <script>
235
  (function(){
236
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
237
 
238
+ // Check persistent storage and warn
239
+ fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let home=document.getElementById('view-home');if(home){let w=document.createElement('div');w.className='storage-warn';w.textContent='⚠️ Persistent Storage chưa bật. Bài tường AI và bình luận sẽ mất khi rebuild. Bật trong Space Settings.';home.prepend(w);}}});
240
+
241
  function renderSourceDetails(post,container){
242
  let details=post.source_details||[];if(!details.length)return;
243
  let box=document.createElement('div');box.className='source-detail-box';
244
+ box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài viết nguồn (nhấn để đọc trên VNEWS)</h3>'+details.map((s,i)=>`<div class="source-detail-item" onclick="if(typeof readArticle==='function'&&'${esc(s.url||'')}')readArticle('${esc(s.url||'')}')"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,400))}</div><span class="source-read-btn">📖 Đọc trên VNEWS</span></div>`).join('');
245
  container.appendChild(box);
246
  details.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
247
 
 
250
  let p=wall[i];if(!p)return;showView('view-article');
251
  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}">`:''}`;
252
  h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;
253
+ h+=`<div class="article-actions"><button class="primary" onclick="rewriteTopicPost('${esc(p.id)}')">🤖 Rewrite AI đăng tường</button><button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤 Chia sẻ</button></div>`;
254
+ h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi bất cứ gì về nội dung này..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;
255
  document.getElementById('view-article').innerHTML=h;
256
  let art=document.querySelector('.article-view');if(art&&p.source_details)renderSourceDetails(p,art);
257
  window.scrollTo(0,0);}
258
  window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
259
 
260
  window.rewriteTopicPost=async function(postId){
261
+ let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang scrape nguồn & rewrite...';}
262
+ try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:postId})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã scrape lại các nguồn, rewrite và đăng bản mới!');}catch(e){alert('Lỗi: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}}};
263
 
264
  window.askAIWall=async function(i){
265
  let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');
266
+ document.getElementById('article-ai-ans').textContent='Đang hỏi...';
267
  let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];
268
+ let p=wall[i]||{};let context=(p.text||'');for(let s of (p.source_details||[]))context+='\n'+(s.content||'');
269
  try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:context.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
270
 
271
+ window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
272
+ window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';}
273
+ function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right,.short-action-panel');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='short-action-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="ico" style="width:42px;height:42px;border-radius:50%;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-size:20px">💬</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);});}
274
 
275
+ // Regular articles: single rewrite + ask
276
+ function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;
277
+ art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});
278
+ art.querySelectorAll('[data-rewrite]').forEach((e,i)=>{if(i>0)e.remove();});
279
+ if(!art.querySelector('[data-rewrite]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rewrite','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){alert('Không có URL');return;}b.disabled=true;b.textContent='Đang rewrite...';fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json()).then(j=>{if(j.post)alert('Đã rewrite đăng tường!');else alert(j.error||'Lỗi')}).catch(e=>alert(e.message)).finally(()=>{b.disabled=false;b.textContent='🤖 Rewrite AI đăng tường'})};a.insertBefore(b,a.firstChild);}}
 
 
 
 
 
280
  if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi về bài viết..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
281
+ window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}}
282
+ window.rewriteCurrentArticle=function(){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url)return alert('Không có URL');fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json()).then(j=>{if(j.post)alert('Đã rewrite và đăng tường!');else alert(j.error||'Lỗi')}).catch(e=>alert(e.message))};
 
 
 
 
283
 
284
+ let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,600);return ret;}}
285
+ let _h=false;function dH(){if(_h)return;_h=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
286
+ if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
287
+ setInterval(()=>{patchArticle();patchShortBtns();},1500);
288
  })();
289
  </script>
290
  '''
291
 
292
  @app.get('/')
293
+ async def _index():
294
  html=f5.f4.f3.f2.f1._load_index_html()
295
  body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
296
  body+=getattr(f6,'FINAL6_INJECT','')