Spaces:
Sleeping
Sleeping
| """Final4 runtime: fix topic button visibility, shorts home feed, AI asking for videos/articles.""" | |
| import re, time, json, os, requests | |
| from urllib.parse import urlparse | |
| import ai_runtime_final3 as f3 | |
| from ai_runtime_final3 import app, base, rt, HTMLResponse, JSONResponse, Request, Query | |
| try: | |
| import main as main_mod | |
| except Exception: | |
| main_mod=None | |
| AI_INTERACTIONS_FILE=f3.AI_INTERACTIONS_FILE | |
| _SHORTS_CACHE={"t":0,"d":[]} | |
| SHORT_CHANNELS=f3.SHORT_CHANNELS | |
| def clean(s): | |
| import html as html_lib | |
| return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip() | |
| def _domain(u): | |
| try:return urlparse(u or '').netloc.replace('www.','') | |
| except Exception:return '' | |
| def _load_json(path,default): | |
| try: | |
| if os.path.exists(path): | |
| with open(path,'r',encoding='utf-8') as f:return json.load(f) | |
| except Exception:pass | |
| return default | |
| def _save_json(path,data): | |
| try: | |
| os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp' | |
| with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False) | |
| os.replace(tmp,path) | |
| except Exception:pass | |
| def _fallback_shorts(): | |
| out=[];seen=set() | |
| candidates=[] | |
| try:candidates+=(getattr(main_mod,'SHORTS_FALLBACK',[]) or []) | |
| except Exception:pass | |
| try:candidates+=(getattr(rt,'SHORTS_FALLBACK',[]) or []) | |
| except Exception:pass | |
| # hard fallback if imports fail | |
| hard=[('Lu_iCQ5YwNM','Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí','baodantri7941'),('CwWvijF8BOA','Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí','baodantri7941'),('7Pd6vZ2Lz1M','Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS','baosuckhoedoisongboyte'),('SlHLt_ZyPiE','Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS','baosuckhoedoisongboyte')] | |
| for vid,title,ch in hard: | |
| candidates.append({'id':vid,'title':title,'channel':ch,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt'}) | |
| for v in candidates: | |
| vid=v.get('id') or '' | |
| if vid and vid not in seen: | |
| seen.add(vid) | |
| if not v.get('link'):v['link']='https://www.youtube.com/watch?v='+vid | |
| if not v.get('img'):v['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg' | |
| v['source']='yt';out.append(v) | |
| return out | |
| def _fresh_shorts(): | |
| items=[];seen=set() | |
| for ch in SHORT_CHANNELS: | |
| got=f3._youtube_shorts_ytdlp(ch,24) or f3._youtube_shorts_html(ch,24) | |
| for v in got: | |
| vid=v.get('id') | |
| if vid and vid not in seen: | |
| seen.add(vid);items.append(v) | |
| for v in _fallback_shorts(): | |
| vid=v.get('id') | |
| if vid and vid not in seen: | |
| seen.add(vid);items.append(v) | |
| return items[:60] | |
| # Remove endpoints/root to override. | |
| _PATCH={('/api/shorts','GET'),('/api/ai/interact','POST'),('/api/article/ask','POST'),('/','GET')} | |
| app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)] | |
| def api_shorts_final4(refresh:int=Query(default=0)): | |
| now=time.time() | |
| if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:return JSONResponse(_SHORTS_CACHE['d']) | |
| data=_fresh_shorts() | |
| _SHORTS_CACHE.update({'t':now,'d':data}) | |
| return JSONResponse(data) | |
| async def ai_interact_final4(request:Request): | |
| body=await request.json();pid=str(body.get('id','')).strip();kind=str(body.get('kind','wall')).strip();action=str(body.get('action','')).strip();text=clean(body.get('text',''));context=clean(body.get('context',''));title=clean(body.get('title','')) | |
| if not pid:return JSONResponse({'error':'missing id'},status_code=400) | |
| db=_load_json(AI_INTERACTIONS_FILE,{}) | |
| key=kind+':'+pid | |
| st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]} | |
| if action=='view':st['views']=int(st.get('views',0))+1 | |
| elif action=='like':st['likes']=int(st.get('likes',0))+1 | |
| elif action=='comment' and text: | |
| st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80] | |
| elif action=='ask' and text: | |
| if kind in ('ai','short','wall'): | |
| posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{}) | |
| title=title or p.get('title','');context=context or (p.get('text') or '') | |
| # For YouTube shorts, frontend sends title/context because AI cannot watch video. | |
| if not context:context=title or pid | |
| prompt=f"""Bạn là trợ lý VNEWS. Trả lời chi tiết bằng tiếng Việt dựa trên thông tin có sẵn về video/bài viết. | |
| Tiêu đề/ngữ cảnh: {title} | |
| Nội dung mô tả: {context[:5000]} | |
| Câu hỏi người dùng: {text} | |
| Yêu cầu: | |
| - Nếu là video YouTube/Shorts và chỉ có tiêu đề, hãy nói rõ rằng bạn suy luận từ tiêu đề/mô tả, không khẳng định đã xem video. | |
| - Trả lời cụ thể, có giải thích, không quá ngắn. | |
| """ | |
| ans=await base.qwen_generate(prompt,max_tokens=900) | |
| if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại cụ thể hơn.' | |
| st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1500],'ts':int(time.time())});st['asks']=st['asks'][:50] | |
| db[key]=st;_save_json(AI_INTERACTIONS_FILE,db) | |
| return JSONResponse({'stats':st}) | |
| async def article_ask(request:Request): | |
| body=await request.json();url=clean(body.get('url',''));question=clean(body.get('question','')) | |
| if not question:return JSONResponse({'error':'missing question'},status_code=400) | |
| title='';raw='' | |
| try: | |
| data=None | |
| if url and hasattr(f3.f2.f1,'_scrape_url_article_only'): | |
| data=f3.f2.f1._scrape_url_article_only(url) | |
| if not data and url:data=base.scrape_any_url(url) | |
| if data: | |
| title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip() | |
| except Exception:pass | |
| context=raw[:12000] if raw else clean(body.get('context',''))[:12000] | |
| prompt=f"""Bạn là trợ lý đọc hiểu bài viết của VNEWS. Hãy trả lời chi tiết câu hỏi của người dùng dựa trên bài viết. | |
| Tiêu đề bài: {title} | |
| Nội dung bài: | |
| {context} | |
| Câu hỏi: {question} | |
| Yêu cầu: | |
| - Trả lời bằng tiếng Việt. | |
| - Dựa sát nội dung bài, nếu bài không có thông tin thì nói rõ. | |
| - Giải thích chi tiết, có gạch đầu dòng khi hữu ích. | |
| """ | |
| ans=await base.qwen_generate(prompt,max_tokens=1200) | |
| if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại hoặc rút gọn câu hỏi.' | |
| return JSONResponse({'answer':ans,'title':title}) | |
| FINAL4_INJECT = r''' | |
| <style> | |
| /* Ensure topic Qwen button is visible; earlier patches hide any button containing “chủ đề”. */ | |
| .topic-final4{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important;margin-top:6px}.topic-final4 input,.topic-final4 button{display:block!important;width:100%!important;box-sizing:border-box!important}.topic-final4 button{background:#2d8659!important;color:#fff!important;border:0!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important}.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:70px;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}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important}.ai-compose-row:has(#ai-url-input) input,.ai-compose-row:has(#ai-url-input) button{width:100%!important} | |
| </style> | |
| <script> | |
| (function(){ | |
| function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));} | |
| let shortsMap={}; | |
| function ensureTopicButtonFinal4(){let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final4')){let row=document.createElement('div');row.className='topic-final4';row.innerHTML='<input id="ai-topic-input-final4" placeholder="Nhập chủ đề để Qwen2.5VL tạo bài lên Tường AI..."><button id="ai-topic-btn-final4" onclick="createTopicPostFinal4()">✨ Tạo bài bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);}let b=document.getElementById('ai-topic-btn-final4');if(b){b.style.display='block';b.textContent='✨ Tạo bài bằng Qwen';}} | |
| window.createTopicPostFinal4=async function(){let inp=document.getElementById('ai-topic-input-final4');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final4');if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(window.finalWall)window.finalWall.unshift(j.post);if(window.finalWall3)window.finalWall3.unshift(j.post);if(inp)inp.value='';if(window.renderWall)window.renderWall();alert('Đã tạo bài bằng Qwen và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài bằng Qwen'}}}; | |
| // Guarantee Shorts slide appears on home even if previous loadHome missed it. | |
| async function ensureShortsHome(){let home=document.getElementById('view-home');if(!home||document.getElementById('shorts-final4'))return;let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.id='shorts-final4';wrap.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Cập nhật YouTube</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{shortsMap[a.id]=a;h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose')||home.firstChild;if(after)after.after(wrap);else home.prepend(wrap);} | |
| // Patch ask for YouTube shorts: AI receives title/context. | |
| let oldShortAct=window.shortAct; | |
| window.shortAct=async function(kind,id,action,text=''){let meta=shortsMap[id]||{};let url='/api/ai/interact';let body={id,kind:kind==='yt'?'yt':kind,action,text,title:meta.title||'',context:meta.title?('Video Shorts YouTube từ kênh '+(meta.channel||'')+'. Tiêu đề: '+meta.title):''};let r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;}; | |
| window.submitShortAsk=async function(kind,id){let t=document.getElementById('short-ask-text').value.trim();if(!t)return;let st=await shortAct(kind,id,'ask',t);let a=(st.asks&&st.asks[0]&&st.asks[0].a)||'Chưa có trả lời';document.getElementById('short-answer').innerHTML='<p style="white-space:pre-wrap;color:#ccc">'+esc(a)+'</p>';}; | |
| // Patch openTikTok to populate shortsMap. | |
| let oldOpenTikTok=window.openTikTok; | |
| window.openTikTok=async function(type,startIdx){if(type==='shorts'){let arts=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);arts.forEach(a=>{if(a.id)shortsMap[a.id]=a});}return oldOpenTikTok?oldOpenTikTok(type,startIdx):null;}; | |
| function addArticleAskBox(){let view=document.getElementById('view-article');if(!view||document.getElementById('article-ai-ask'))return;let art=view.querySelector('.article-view');if(!art)return;let box=document.createElement('div');box.id='article-ai-ask';box.className='article-ai-ask';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="Nhập câu hỏi cần AI trả lời chi tiết về bài viết..."></textarea><button onclick="askArticleAI()">Hỏi AI</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);} | |
| window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi trước');let ans=document.getElementById('article-ai-answer');ans.textContent='Đang hỏi AI...';let url=(window._currentArticle&&window._currentArticle.url)||((typeof _currentArticle!=='undefined'&&_currentArticle.url)||'');let context=document.querySelector('.article-view')?.innerText||'';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||j.error||'Không có trả lời';}catch(e){ans.textContent='Lỗi hỏi AI: '+e.message}} | |
| let oldReadArticle=window.readArticle;if(oldReadArticle){window.readArticle=async function(){let ret=await oldReadArticle.apply(this,arguments);setTimeout(addArticleAskBox,700);return ret;}} | |
| setTimeout(()=>{ensureTopicButtonFinal4();ensureShortsHome();},1000);setInterval(()=>{ensureTopicButtonFinal4();if(document.getElementById('view-home')?.classList.contains('active'))ensureShortsHome();addArticleAskBox();},2000); | |
| })(); | |
| </script> | |
| ''' | |
| async def index_final4(): | |
| html=f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f3.f2.f1.FINAL_INJECT+f3.FINAL3_INJECT+FINAL4_INJECT | |
| return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body) | |