bep40 commited on
Commit
09b68ef
·
verified ·
1 Parent(s): 144a193

Fix: single rewrite btn, 24h TTL for wall/shorts, AI ask uses article content"

Browse files
Files changed (1) hide show
  1. ai_runtime_patch_fast.py +117 -80
ai_runtime_patch_fast.py CHANGED
@@ -1,5 +1,5 @@
1
- """Patch over c1e2703: persistent source_details after reload, topic rewrite, short comments saved."""
2
- import re, threading, time, json, os
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
@@ -8,20 +8,28 @@ def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
8
 
9
  DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
10
  SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
 
11
 
12
- def _load_comments():
13
  try:
14
- if os.path.exists(SHORT_COMMENTS_FILE):
15
- with open(SHORT_COMMENTS_FILE,'r',encoding='utf-8') as f:return json.load(f)
16
  except Exception:pass
17
- return {}
18
- def _save_comments(db):
19
  try:
20
- os.makedirs(os.path.dirname(SHORT_COMMENTS_FILE),exist_ok=True);tmp=SHORT_COMMENTS_FILE+'.tmp'
21
- with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
22
- os.replace(tmp,SHORT_COMMENTS_FILE)
23
  except Exception:pass
24
 
 
 
 
 
 
 
 
25
  # ===== BACKGROUND PREFETCH =====
26
  _bg_home_cache={"t":0,"d":[]}
27
  _bg_shorts_cache={"t":0,"d":[]}
@@ -42,6 +50,7 @@ def _bg_refresh():
42
  vid=v.get('id') or ''
43
  if vid and vid not in seen:seen.add(vid);out.append(v)
44
  if out:_bg_shorts_cache.update({"t":time.time(),"d":out[:40]})
 
45
  except Exception:pass
46
  finally:_bg_loading=False
47
 
@@ -58,6 +67,8 @@ app.router.routes=[r for r in app.router.routes if not (
58
  (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set())) or
59
  (getattr(r,'path',None)=='/api/short/comments' and 'GET' in getattr(r,'methods',set())) or
60
  (getattr(r,'path',None)=='/api/short/comment' and 'POST' in getattr(r,'methods',set())) or
 
 
61
  (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
62
  )]
63
 
@@ -81,23 +92,61 @@ def api_shorts_instant(refresh:int=Query(default=0)):
81
  if hasattr(f6,'api_shorts_final6'):return f6.api_shorts_final6(refresh=refresh)
82
  return JSONResponse([])
83
 
84
- # ===== SHORT COMMENTS (persistent) =====
 
 
 
 
 
 
 
85
  @app.get('/api/short/comments')
86
  def get_short_comments(id:str=Query(...)):
87
- db=_load_comments()
88
  return JSONResponse({'comments':db.get(id,[])})
89
 
90
  @app.post('/api/short/comment')
91
  async def post_short_comment(request:Request):
92
  body=await request.json();vid=str(body.get('id','')).strip();text=clean(body.get('text',''))
93
  if not vid or not text:return JSONResponse({'error':'missing id or text'},status_code=400)
94
- db=_load_comments()
95
- comments=db.get(vid,[])
96
- comments.insert(0,{'text':text[:300],'ts':int(time.time())})
97
- db[vid]=comments[:100]
98
- _save_comments(db)
99
  return JSONResponse({'comments':db[vid]})
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  # ===== TOPIC POST =====
102
  @app.post('/api/topic_post')
103
  async def topic_post_focused(request:Request):
@@ -122,7 +171,6 @@ QUY TẮC:
122
  4. Không mở đầu bằng "Dưới đây là".
123
  5. Cuối bài có "Nguồn tham khảo".
124
  """
125
- import asyncio
126
  text=None
127
  try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
128
  except Exception:text=None
@@ -137,102 +185,91 @@ QUY TẮC:
137
 
138
  # ===== FRONTEND =====
139
  PATCH_INJECT=r'''
140
- <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-meta{font-size:10px;color:#888;margin:3px 0}.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;text-decoration:none;cursor:pointer}.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}</style>
141
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
142
  <script>
143
  (function(){
144
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
145
 
146
- // === 1) Source details persistent after reload ===
147
  function renderSourceDetails(post,container){
148
  let details=post.source_details||[];if(!details.length)return;
149
  let box=document.createElement('div');box.className='source-detail-box';
150
- box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài viết nguồn</h3>'+details.map((s,i)=>{
151
- let imgHtml=s.img?`<img src="${esc(s.img)}" loading="lazy" onerror="this.style.display='none'">`:'';
152
- return `<div class="source-detail-item">${imgHtml}<div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-meta">${esc(s.via||'')}</div><div class="source-detail-content">${esc((s.content||'').slice(0,600))}</div>${s.url?`<a onclick="event.preventDefault();if(typeof readArticle==='function')readArticle('${esc(s.url)}');else window.open('${esc(s.url)}','_blank')">📖 Đọc trên VNEWS</a>`:''}</div>`}).join('');
153
  container.appendChild(box);
154
- // Fetch images for sources that don't have img yet.
155
- details.forEach((s,i)=>{if(s.img||!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(()=>{});});}
156
 
157
- // === 2) Patch AI wall read to show source_details + rewrite button ===
158
  async function readAIWallPost(i){
159
  let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];
160
- let p=wall[i];if(!p)return;
161
- showView('view-article');
162
- let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view" data-post-id="${p.id}"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}`;
163
  if(p.sources&&p.sources.length){h+=`<div class="article-summary"><b>Nguồn:</b> ${p.sources.map(s=>esc(s.via||s.title||'')).join(', ')}</div>`;}
164
  h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;
165
- h+=`<div class="article-actions"><button class="primary" onclick="rewriteTopicPost(${i})">🤖 AI viết lại & đăng tường</button><button onclick="doShare('${esc(p.title)}','${location.origin}/aw?post=${p.id}','${esc(p.img||'')}')">📤 Chia sẻ</button></div>`;
 
 
166
  h+=`</div>`;
167
  document.getElementById('view-article').innerHTML=h;
168
- // Render source details from saved data.
169
- let art=document.querySelector('.article-view');
170
- if(art&&p.source_details&&p.source_details.length)renderSourceDetails(p,art);
171
  window.scrollTo(0,0);}
172
 
173
- window.readAIWallPost=readAIWallPost;
174
- window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
175
 
176
- // === 3) Rewrite topic post using its source URLs ===
177
- window.rewriteTopicPost=async function(i){
 
 
 
 
 
 
178
  let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];
179
- let p=wall[i];if(!p)return alert('Bài không tồn tại');
180
- let urls=(p.source_details||[]).map(s=>s.url).filter(Boolean);
181
- if(!urls.length&&p.sources)urls=p.sources.map(s=>s.url).filter(Boolean);
182
- if(!urls.length)return alert('Bài này không có link nguồn để rewrite.');
183
- let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang rewrite...';}
184
- try{
185
- let r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:urls[0]})});
186
- let j=await r.json();
187
- if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
188
- alert('Đã rewrite bài nguồn đầu tiên và đăng lên Tường AI.');
189
- if(typeof loadPatchedWall==='function')loadPatchedWall();
190
- }catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🤖 AI viết lại & đăng tường';}}}
191
-
192
- // === 4) Short comments: persistent + display ===
193
  window.openShortComments=async function(id){
194
  let panel=document.getElementById('short-cmt-panel');
195
  let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));
196
  let cmts=j.comments||[];
197
  panel.innerHTML=`<h3 style="color:#5cb87a;font-size:14px">💬 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ó bình luận</div>'}</div><textarea id="cmt-text" placeholder="Nhập 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>`;
198
  panel.classList.add('active');}
199
-
200
  window.submitShortCmt=async function(id){
201
  let text=document.getElementById('cmt-text')?.value.trim();if(!text)return;
202
  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:[]}));
203
- let cmts=j.comments||[];
204
- document.getElementById('cmt-list').innerHTML=cmts.map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');
205
  document.getElementById('cmt-text').value='';}
206
 
207
- // === 5) Patch shorts feed to show comment count + open panel ===
208
- function patchShortCommentButtons(){
209
- document.querySelectorAll('.tiktok-slide').forEach(sl=>{
210
- if(sl.dataset.cmtPatched)return;sl.dataset.cmtPatched='1';
211
- let id=sl.dataset.id||'';if(!id)return;
212
- let right=sl.querySelector('.tiktok-right,.short-action-panel');
213
- if(!right)return;
214
- let existing=right.querySelector('[data-cmt-btn]');if(existing)return;
215
- let btn=document.createElement('button');btn.className='short-action-btn';btn.setAttribute('data-cmt-btn','1');
216
- 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><span>BL</span>';
217
- btn.onclick=function(e){e.stopPropagation();openShortComments(id);};
218
- right.appendChild(btn);});}
219
-
220
- // === 6) Add rewrite to regular articles ===
221
- function addRewriteButton(){let art=document.querySelector('#view-article .article-view');if(!art||art.querySelector('[data-rewrite]'))return;let actions=art.querySelector('.article-actions');if(!actions){actions=document.createElement('div');actions.className='article-actions';art.appendChild(actions);}let btn=document.createElement('button');btn.className='primary';btn.setAttribute('data-rewrite','1');btn.textContent='🤖 AI viết lại & đăng tường';btn.onclick=function(){if(typeof rewriteCurrentArticle==='function')rewriteCurrentArticle();else alert('Chức năng rewrite chưa sẵn sàng.')};actions.insertBefore(btn,actions.firstChild);}
222
-
223
- // === 7) Source links open in-app ===
224
- function patchSourceLinksInApp(){document.querySelectorAll('.source-detail-item a[onclick]').forEach(a=>{});/* Already using onclick in renderSourceDetails */}
225
-
226
- // === 8) Patch readArticle for rewrite button ===
227
- let oldReadArticle=window.readArticle;
228
- if(oldReadArticle){window.readArticle=async function(){let ret=await oldReadArticle.apply(this,arguments);setTimeout(addRewriteButton,600);return ret;}}
229
-
230
- // === 9) Defer hot topics ===
 
231
  let _hotLoaded=false;function deferHot(){if(_hotLoaded)return;_hotLoaded=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
232
  if(document.readyState==='complete')deferHot();else window.addEventListener('load',deferHot);
233
 
234
- setInterval(()=>{addRewriteButton();patchShortCommentButtons();},1500);
235
- setTimeout(()=>{addRewriteButton();patchShortCommentButtons();},1000);
236
  })();
237
  </script>
238
  '''
 
1
+ """Patch over c1e2703: single rewrite button, 24h TTL for AI wall/shorts, AI ask uses content."""
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
 
8
 
9
  DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
10
  SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
11
+ TTL_24H=86400
12
 
13
+ def _load_json(path,default):
14
  try:
15
+ if os.path.exists(path):
16
+ with open(path,'r',encoding='utf-8') as f:return json.load(f)
17
  except Exception:pass
18
+ return default
19
+ def _save_json(path,data):
20
  try:
21
+ os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
22
+ with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
23
+ os.replace(tmp,path)
24
  except Exception:pass
25
 
26
+ def _cleanup_old_posts():
27
+ """Remove AI wall posts and shorts older than 24h."""
28
+ now=int(time.time())
29
+ posts=f5.base._load_ai_wall()
30
+ fresh=[p for p in posts if now-int(p.get('ts') or 0)<TTL_24H]
31
+ if len(fresh)<len(posts):f5.base._save_ai_wall(fresh)
32
+
33
  # ===== BACKGROUND PREFETCH =====
34
  _bg_home_cache={"t":0,"d":[]}
35
  _bg_shorts_cache={"t":0,"d":[]}
 
50
  vid=v.get('id') or ''
51
  if vid and vid not in seen:seen.add(vid);out.append(v)
52
  if out:_bg_shorts_cache.update({"t":time.time(),"d":out[:40]})
53
+ _cleanup_old_posts()
54
  except Exception:pass
55
  finally:_bg_loading=False
56
 
 
67
  (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set())) or
68
  (getattr(r,'path',None)=='/api/short/comments' and 'GET' in getattr(r,'methods',set())) or
69
  (getattr(r,'path',None)=='/api/short/comment' and 'POST' in getattr(r,'methods',set())) or
70
+ (getattr(r,'path',None)=='/api/article/ask' and 'POST' in getattr(r,'methods',set())) or
71
+ (getattr(r,'path',None)=='/api/ai_wall' and 'GET' in getattr(r,'methods',set())) or
72
  (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
73
  )]
74
 
 
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 api_ai_wall_with_ttl():
97
+ """Return AI wall posts, filtering out posts older than 24h."""
98
+ now=int(time.time());posts=f5.base._load_ai_wall()
99
+ fresh=[p for p in posts if now-int(p.get('ts') or 0)<TTL_24H]
100
+ return JSONResponse({'posts':fresh})
101
+
102
+ # ===== SHORT COMMENTS =====
103
  @app.get('/api/short/comments')
104
  def get_short_comments(id:str=Query(...)):
105
+ db=_load_json(SHORT_COMMENTS_FILE,{})
106
  return JSONResponse({'comments':db.get(id,[])})
107
 
108
  @app.post('/api/short/comment')
109
  async def post_short_comment(request:Request):
110
  body=await request.json();vid=str(body.get('id','')).strip();text=clean(body.get('text',''))
111
  if not vid or not text:return JSONResponse({'error':'missing id or text'},status_code=400)
112
+ db=_load_json(SHORT_COMMENTS_FILE,{})
113
+ comments=db.get(vid,[]);comments.insert(0,{'text':text[:300],'ts':int(time.time())})
114
+ db[vid]=comments[:100];_save_json(SHORT_COMMENTS_FILE,db)
 
 
115
  return JSONResponse({'comments':db[vid]})
116
 
117
+ # ===== ARTICLE ASK AI (based on article content) =====
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ý đọc hiểu VNEWS. Trả lời câu hỏi DỰA TRÊN NỘI DUNG BÀI VIẾT bên dưới.
131
+
132
+ BẮT BUỘC:
133
+ - Chỉ dùng thông tin CÓ TRONG bài viết để trả lời.
134
+ - Nếu bài viết không có thông tin liên quan đến câu hỏi, nói rõ "Bài viết không đề cập đến vấn đề này."
135
+ - Không bịa thêm chi tiết ngoài bài.
136
+ - Trích dẫn/tham chiếu các đoạn cụ thể trong bài khi trả lời.
137
+
138
+ Tiêu đề bài: {title}
139
+ Nội dung bài:
140
+ {raw[:12000]}
141
+
142
+ Câu hỏi: {question}
143
+
144
+ Trả lời bằng tiếng Việt, chi tiết, dựa sát nội dung bài.
145
+ """
146
+ ans=await f5.base.qwen_generate(prompt,max_tokens=1200)
147
+ if not ans:ans='AI chưa trả lời được. Thử hỏi cụ thể hơn.'
148
+ return JSONResponse({'answer':ans,'title':title})
149
+
150
  # ===== TOPIC POST =====
151
  @app.post('/api/topic_post')
152
  async def topic_post_focused(request:Request):
 
171
  4. Không mở đầu bằng "Dưới đây là".
172
  5. Cuối bài có "Nguồn tham khảo".
173
  """
 
174
  text=None
175
  try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
176
  except Exception:text=None
 
185
 
186
  # ===== FRONTEND =====
187
  PATCH_INJECT=r'''
188
+ <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-meta{font-size:10px;color:#888;margin:3px 0}.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;text-decoration:none;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>
189
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
190
  <script>
191
  (function(){
192
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
193
 
194
+ // === Source details render (persistent after reload) ===
195
  function renderSourceDetails(post,container){
196
  let details=post.source_details||[];if(!details.length)return;
197
  let box=document.createElement('div');box.className='source-detail-box';
198
+ 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-meta">${esc(s.via||'')}</div><div class="source-detail-content">${esc((s.content||'').slice(0,600))}</div>${s.url?`<a onclick="event.preventDefault();if(typeof readArticle==='function')readArticle('${esc(s.url)}')">📖 Đọc trên VNEWS</a>`:''}</div>`).join('');
 
 
199
  container.appendChild(box);
200
+ 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(()=>{});});}
 
201
 
202
+ // === AI Wall read with source_details + rewrite + ask AI ===
203
  async function readAIWallPost(i){
204
  let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];
205
+ let p=wall[i];if(!p)return;showView('view-article');
206
+ 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}">`:''}`;
 
207
  if(p.sources&&p.sources.length){h+=`<div class="article-summary"><b>Nguồn:</b> ${p.sources.map(s=>esc(s.via||s.title||'')).join(', ')}</div>`;}
208
  h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;
209
+ let firstUrl=(p.source_details||[]).find(s=>s.url)?.url||(p.sources||[]).find(s=>s.url)?.url||'';
210
+ h+=`<div class="article-actions">${firstUrl?`<button class="primary" onclick="rewriteFromUrl('${esc(firstUrl)}')">🤖 Tóm tắt AI đăng tường</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤 Chia sẻ</button></div>`;
211
+ h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a;margin-bottom:6px">🤖 Hỏi AI về bài viết</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung bài viết này..."></textarea><button onclick="askAIWall(${i})">Hỏi AI</button><div id="article-ai-ans" class="article-ai-answer"></div></div>`;
212
  h+=`</div>`;
213
  document.getElementById('view-article').innerHTML=h;
214
+ let art=document.querySelector('.article-view');if(art&&p.source_details)renderSourceDetails(p,art);
 
 
215
  window.scrollTo(0,0);}
216
 
217
+ window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
 
218
 
219
+ window.rewriteFromUrl=async function(url){
220
+ if(!url)return alert('Không có URL nguồn');
221
+ let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tóm tắt...';}
222
+ try{let r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});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(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🤖 Tóm tắt AI đăng tường';}}};
223
+
224
+ window.askAIWall=async function(i){
225
+ let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');
226
+ let ans=document.getElementById('article-ai-ans');ans.textContent='Đang hỏi AI...';
227
  let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];
228
+ let p=wall[i]||{};let context=p.text||'';let url=(p.source_details||[]).find(s=>s.url)?.url||(p.sources||[]).find(s=>s.url)?.url||'';
229
+ try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context})});let j=await r.json();ans.textContent=j.answer||'Không có trả lời';}catch(e){ans.textContent='Lỗi: '+e.message}};
230
+
231
+ // === Short comments ===
 
 
 
 
 
 
 
 
 
 
232
  window.openShortComments=async function(id){
233
  let panel=document.getElementById('short-cmt-panel');
234
  let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));
235
  let cmts=j.comments||[];
236
  panel.innerHTML=`<h3 style="color:#5cb87a;font-size:14px">💬 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ó bình luận</div>'}</div><textarea id="cmt-text" placeholder="Nhập 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>`;
237
  panel.classList.add('active');}
 
238
  window.submitShortCmt=async function(id){
239
  let text=document.getElementById('cmt-text')?.value.trim();if(!text)return;
240
  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:[]}));
241
+ document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');
 
242
  document.getElementById('cmt-text').value='';}
243
 
244
+ // === Patch shorts to add comment button ===
245
+ 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><span>BL</span>';btn.onclick=function(e){e.stopPropagation();openShortComments(id);};right.appendChild(btn);});}
246
+
247
+ // === Only ONE rewrite button for regular articles (not duplicate) ===
248
+ function addSingleRewriteBtn(){let art=document.querySelector('#view-article .article-view');if(!art)return;
249
+ // Remove any duplicates first.
250
+ let btns=art.querySelectorAll('[data-rewrite]');if(btns.length>1){for(let i=1;i<btns.length;i++)btns[i].remove();}
251
+ if(btns.length>=1)return;
252
+ let actions=art.querySelector('.article-actions');if(!actions)return;
253
+ let btn=document.createElement('button');btn.className='primary';btn.setAttribute('data-rewrite','1');btn.textContent='🤖 Tóm tắt AI đăng tường';
254
+ btn.onclick=function(){if(typeof rewriteCurrentArticle==='function')rewriteCurrentArticle();else alert('Chức năng rewrite chưa sẵn sàng.')};
255
+ actions.insertBefore(btn,actions.firstChild);}
256
+
257
+ // === Ask AI box in regular articles ===
258
+ function addArticleAskBox(){let art=document.querySelector('#view-article .article-view');if(!art||art.querySelector('.article-ai-ask'))return;
259
+ let box=document.createElement('div');box.className='article-ai-ask';
260
+ box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:6px">🤖 Hỏi AI về bài viết</h3><textarea id="article-ai-question" placeholder="Hỏi về nội dung bài viết..."></textarea><button onclick="askArticleAI()">Hỏi AI</button><div id="article-ai-answer" class="article-ai-answer"></div>';
261
+ art.appendChild(box);}
262
+ 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 AI...';let url=(window._currentArticle&&window._currentArticle.url)||'';let context=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})});let j=await r.json();ans.textContent=j.answer||'Không có trả lời';}catch(e){ans.textContent='Lỗi: '+e.message}}
263
+
264
+ // === Patch readArticle ===
265
+ let oldRA=window.readArticle;
266
+ if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(()=>{addSingleRewriteBtn();addArticleAskBox();},600);return ret;}}
267
+
268
+ // === Defer non-critical ===
269
  let _hotLoaded=false;function deferHot(){if(_hotLoaded)return;_hotLoaded=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
270
  if(document.readyState==='complete')deferHot();else window.addEventListener('load',deferHot);
271
 
272
+ setInterval(()=>{addSingleRewriteBtn();patchShortButtons();addArticleAskBox();},1500);
 
273
  })();
274
  </script>
275
  '''