Spaces:
Running
Running
Fix AI URL input persistence and improve internet-sourced topic posts
Browse files
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
|
| 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 |
-
|
| 102 |
-
r = requests.get(
|
| 103 |
soup = BeautifulSoup(r.text, "lxml")
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
except Exception:
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 146 |
-
|
|
|
|
|
|
|
| 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 |
-
-
|
| 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
|
| 173 |
"""
|
| 174 |
-
text = await qwen_generate(prompt, image_url=image, max_tokens=
|
| 175 |
if not text:
|
| 176 |
-
|
| 177 |
-
|
|
|
|
| 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 241 |
let aiWall=[];
|
| 242 |
-
|
| 243 |
-
function
|
| 244 |
-
|
|
|
|
| 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
|
| 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|