mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat: add /ask/ quick-question page — mobile one-tap send to Discord via webhook
This commit is contained in:
@@ -7,6 +7,7 @@ from django.views.generic import TemplateView
|
||||
from django.http import FileResponse, Http404
|
||||
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
|
||||
from links.file_views import PublicFileView, import_image_view
|
||||
from links.ask_views import ask_page as ask_page_view
|
||||
from django.urls import path, include, re_path
|
||||
from django.conf.urls.i18n import i18n_patterns
|
||||
import os
|
||||
@@ -60,6 +61,10 @@ urlpatterns = [
|
||||
# JBOT AI Robot Face SPA
|
||||
path('jbot/', include('jbot.urls')),
|
||||
|
||||
# Ask — 手机一键提问(必须在 links.urls 的 alias catch-all 之前)
|
||||
path('ask/', ask_page_view, name='ask-page'),
|
||||
path('ask/<str:question>/', ask_page_view, name='ask-page-q'),
|
||||
|
||||
# Import external image by URL — /import/images/<path:image_url> (also plural alias)
|
||||
path('import/images/<path:image_url>', import_image_view, name='import-image'),
|
||||
path('imports/images/<path:image_url>', import_image_view, name='imports-image'),
|
||||
|
||||
@@ -163,6 +163,11 @@ spec:
|
||||
key: key_id
|
||||
- name: FINNHUB_API_KEY
|
||||
value: "d7hbngpr01qhiu0b2pv0d7hbngpr01qhiu0b2pvg"
|
||||
- name: DISCORD_WEBHOOK_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: links-discord-webhook
|
||||
key: webhook_url
|
||||
- name: REDIS_URL
|
||||
value: "redis://192.168.1.2:6379/0"
|
||||
- name: CRAWL4AI_API_URL
|
||||
|
||||
@@ -5,6 +5,7 @@ from . import post_views
|
||||
from . import api_views
|
||||
from . import file_views
|
||||
from . import bookmark_views
|
||||
from . import ask_views
|
||||
|
||||
# Create a router and register our viewsets with it
|
||||
router = DefaultRouter(trailing_slash=False)
|
||||
@@ -18,6 +19,7 @@ router.register('bookmarks', bookmark_views.BookmarkViewSet, basename='api-bookm
|
||||
# The API URLs are determined automatically by the router
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('ask/', ask_views.ask_api, name='api-ask'),
|
||||
path('images/', include('links.image_urls')),
|
||||
|
||||
]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Ask view — 手机一键提问入口(方案 B:投递到 Discord)。
|
||||
|
||||
用户打开 http://go/ask/我的问题 即可把问题直接发到 Discord #general 并 @小黑,
|
||||
小黑在 Discord 里原生回复(含工具调用过程、长文排版),无需打开 Discord 操作。
|
||||
|
||||
调用链:手机浏览器 → links Django → Discord Webhook(#general)→ 小黑回复在 Discord。
|
||||
Webhook URL 通过环境变量 DISCORD_WEBHOOK_URL 配置(K8s deployment 注入)。
|
||||
|
||||
页面同时提供「打开 Discord」按钮(discord:// deep link 直达 #general 频道)。
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISCORD_BOT_ID = os.environ.get('DISCORD_BOT_ID', '1467845106831855796')
|
||||
DISCORD_DEEPLINK = os.environ.get(
|
||||
'DISCORD_DEEPLINK',
|
||||
'discord://channels/1467846046590959798/1467846047089954952',
|
||||
)
|
||||
|
||||
# 用 webhook 发消息(限制长度,Discord 单条消息上限 2000 字符)
|
||||
MAX_MSG_LEN = 1900
|
||||
|
||||
|
||||
def _post_to_discord(question: str) -> None:
|
||||
"""通过 Discord webhook 把问题发到 #general 并 @小黑。"""
|
||||
webhook_url = os.environ.get('DISCORD_WEBHOOK_URL', '').strip()
|
||||
if not webhook_url:
|
||||
raise RuntimeError('服务端未配置 DISCORD_WEBHOOK_URL,请先配置')
|
||||
|
||||
content = f'<@{DISCORD_BOT_ID}> {question}'[:MAX_MSG_LEN]
|
||||
try:
|
||||
resp = requests.post(webhook_url, json={'content': content}, timeout=15)
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning('[ASK] discord webhook failed: %s', e)
|
||||
raise RuntimeError('发送到 Discord 失败,稍后再试')
|
||||
|
||||
|
||||
def ask_page(request, question=None):
|
||||
"""渲染提问页。question 可为 URL path 段或 ?q= 参数。"""
|
||||
if question is None:
|
||||
question = request.GET.get('q', '')
|
||||
return render(request, 'links/ask.html', {
|
||||
'initial_question': question,
|
||||
'discord_deeplink': DISCORD_DEEPLINK,
|
||||
})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def ask_api(request):
|
||||
"""POST /api/ask/ body: {"q": "问题"} → {"text": "提示文案"}"""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
data = _json.loads(request.body or b'{}')
|
||||
except Exception:
|
||||
return JsonResponse({'error': '无效的请求体'}, status=400)
|
||||
|
||||
question = (data.get('q') or '').strip()
|
||||
if not question:
|
||||
return JsonResponse({'error': '问题不能为空'}, status=400)
|
||||
if len(question) > MAX_MSG_LEN:
|
||||
return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400)
|
||||
|
||||
try:
|
||||
_post_to_discord(question)
|
||||
except Exception as e:
|
||||
logger.warning('[ASK] post failed: %s', e)
|
||||
return JsonResponse({'error': str(e)}, status=502)
|
||||
|
||||
return JsonResponse({'text': '已投递到 Discord,小黑回复会出现在 #general'})
|
||||
@@ -0,0 +1,386 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b0b12">
|
||||
<title>问小黑</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖤</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0b12;
|
||||
--card: #16161f;
|
||||
--card-2: #1d1d29;
|
||||
--text: #ececf1;
|
||||
--muted: #9a9aa8;
|
||||
--accent: #7c6cff;
|
||||
--accent-2: #4facfe;
|
||||
--bubble-user: linear-gradient(135deg, #7c6cff 0%, #4facfe 100%);
|
||||
--danger: #ff6b6b;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
height: 100dvh;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||
background: rgba(11,11,18,.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.avatar {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 18px; color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(124,108,255,.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title { font-size: 16px; font-weight: 700; letter-spacing: .3px; }
|
||||
.subtitle { font-size: 12px; color: var(--muted); margin-top: 1px; }
|
||||
.dot { width: 5px; height: 5px; border-radius: 50%; background: #3ddc84; display: inline-block; margin-right: 5px; }
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
padding: 11px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
animation: pop .22s ease;
|
||||
}
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.bubble.user {
|
||||
align-self: flex-end;
|
||||
background: var(--bubble-user);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.bubble.bot {
|
||||
align-self: flex-start;
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(255,255,255,.07);
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
.bubble.bot pre {
|
||||
background: #0d0d16;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.bubble.bot code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 5px; }
|
||||
.bubble.bot pre code { background: none; padding: 0; }
|
||||
.bubble.bot a { color: var(--accent-2); text-decoration: underline; }
|
||||
.bubble.bot strong { color: #fff; }
|
||||
.bubble.bot h1, .bubble.bot h2, .bubble.bot h3 { font-size: 15px; margin: 8px 0 4px; color: #fff; }
|
||||
.bubble.bot ul, .bubble.bot ol { padding-left: 20px; margin: 6px 0; }
|
||||
.bubble.bot blockquote { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); margin: 6px 0; }
|
||||
.bubble.bot hr { border: none; border-top: 1px solid rgba(255,255,255,.1); margin: 10px 0; }
|
||||
.bubble.bot table { border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||
.bubble.bot th, .bubble.bot td { border: 1px solid rgba(255,255,255,.15); padding: 5px 9px; }
|
||||
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 4px 2px; }
|
||||
.typing span {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--muted);
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: .2s; }
|
||||
.typing span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes blink { 0%, 80%, 100% { opacity: .25; transform: scale(.85); } 40% { opacity: 1; transform: scale(1); } }
|
||||
.welcome {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
padding: 60px 24px 30px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.welcome .big { font-size: 40px; margin-bottom: 12px; }
|
||||
.welcome .hint { background: var(--card); border: 1px solid rgba(255,255,255,.07); border-radius: 10px; padding: 8px 14px; display: inline-block; font-size: 12.5px; color: var(--muted); margin-top: 16px; }
|
||||
.error-banner {
|
||||
align-self: stretch;
|
||||
background: rgba(255,107,107,.1);
|
||||
border: 1px solid rgba(255,107,107,.3);
|
||||
color: var(--danger);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
footer {
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid rgba(255,255,255,.06);
|
||||
background: rgba(11,11,18,.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
#input {
|
||||
flex: 1;
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 22px;
|
||||
padding: 11px 16px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
font-family: inherit;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
#input:focus { border-color: var(--accent); }
|
||||
#input::placeholder { color: #5c5c6e; }
|
||||
#send {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bubble-user);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 4px 14px rgba(124,108,255,.35);
|
||||
transition: transform .12s, opacity .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#send:active { transform: scale(.92); }
|
||||
#send:disabled { opacity: .45; box-shadow: none; }
|
||||
.retry {
|
||||
background: rgba(255,255,255,.08);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
color: var(--text);
|
||||
border-radius: 18px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.open-discord {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, #5865F2 0%, #7c6cff 100%);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
padding: 11px 20px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 16px rgba(88,101,242,.35);
|
||||
animation: pop .25s ease;
|
||||
}
|
||||
.open-discord:active { transform: scale(.96); }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="avatar">黑</div>
|
||||
<div>
|
||||
<div class="title">问小黑</div>
|
||||
<div class="subtitle"><span class="dot"></span>Hermes Agent · 在线</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<div class="welcome" id="welcome">
|
||||
<div class="big">🖤</div>
|
||||
直接输入问题,小黑马上回答。<br>
|
||||
比如「今天墨尔本天气怎么样」
|
||||
<div class="hint">也可以直接在链接后加问题:<br>go/ask/你的问题</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<textarea id="input" rows="1" placeholder="问小黑点什么…" enterkeyhint="send"></textarea>
|
||||
<button id="send" title="发送">➤</button>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const main = document.getElementById('main');
|
||||
const input = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const welcome = document.getElementById('welcome');
|
||||
const initialQuestion = "{{ initial_question|escapejs }}";
|
||||
const discordDeepLink = "{{ discord_deeplink|escapejs }}";
|
||||
|
||||
// ── 极简 markdown 渲染(转义后处理换行/粗体/代码/链接)──
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function mdToHtml(md) {
|
||||
let out = '';
|
||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||
let inCode = false, codeBuf = [];
|
||||
const flushCode = () => {
|
||||
if (codeBuf.length) {
|
||||
out += '<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>';
|
||||
codeBuf = [];
|
||||
}
|
||||
};
|
||||
for (const raw of lines) {
|
||||
const t = raw.trim();
|
||||
if (t.startsWith('```')) {
|
||||
if (inCode) { flushCode(); inCode = false; }
|
||||
else { flushCode(); inCode = true; }
|
||||
continue;
|
||||
}
|
||||
if (inCode) { codeBuf.push(raw); continue; }
|
||||
if (!raw.trim()) { out += '<br>'; continue; }
|
||||
if (/^#{1,6}\s/.test(raw)) {
|
||||
const lvl = raw.match(/^#+/)[0].length;
|
||||
out += `<h${Math.min(lvl,3)}>${inline(raw.replace(/^#+\s*/, ''))}</h${Math.min(lvl,3)}>`;
|
||||
continue;
|
||||
}
|
||||
if (/^[-*]\s+/.test(raw)) { out += `<li>${inline(raw.replace(/^[-*]\s+/, ''))}</li>`; continue; }
|
||||
if (/^\d+\.\s+/.test(raw)) { out += `<li>${inline(raw.replace(/^\d+\.\s+/, ''))}</li>`; continue; }
|
||||
if (/^>\s?/.test(raw)) { out += `<blockquote>${inline(raw.replace(/^>\s?/, ''))}</blockquote>`; continue; }
|
||||
if (/^---+$/.test(raw)) { out += '<hr>'; continue; }
|
||||
if (/^\|.*\|$/.test(raw)) { out += `<div>${inline(raw)}</div>`; continue; }
|
||||
out += `<p>${inline(raw)}</p>`;
|
||||
}
|
||||
flushCode();
|
||||
return out;
|
||||
}
|
||||
function inline(s) {
|
||||
let h = escapeHtml(s);
|
||||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
h = h.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
||||
return h;
|
||||
}
|
||||
|
||||
function addBubble(text, who) {
|
||||
welcome && welcome.remove();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bubble ' + who;
|
||||
if (who === 'bot') div.innerHTML = mdToHtml(text);
|
||||
else div.textContent = text;
|
||||
main.appendChild(div);
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
function addTyping() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bubble bot';
|
||||
div.innerHTML = '<div class="typing"><span></span><span></span><span></span></div>';
|
||||
main.appendChild(div);
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
function addError(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'error-banner';
|
||||
div.textContent = '⚠️ ' + msg;
|
||||
main.appendChild(div);
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function addDiscordButton() {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'open-discord';
|
||||
a.href = discordDeepLink;
|
||||
a.textContent = '📲 打开 Discord 看回复';
|
||||
main.appendChild(a);
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return a;
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
async function ask(question) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
sendBtn.disabled = true;
|
||||
input.value = '';
|
||||
autoResize();
|
||||
addBubble(question, 'user');
|
||||
const typing = addTyping();
|
||||
try {
|
||||
const resp = await fetch('/api/ask/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q: question }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
typing.remove();
|
||||
if (!resp.ok || data.error) {
|
||||
addError(data.error || '请求失败,请重试');
|
||||
} else {
|
||||
addBubble(data.text, 'bot');
|
||||
addDiscordButton();
|
||||
}
|
||||
} catch (e) {
|
||||
typing.remove();
|
||||
addError('网络错误,请检查链接后重试');
|
||||
} finally {
|
||||
busy = false;
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||
}
|
||||
input.addEventListener('input', autoResize);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const q = input.value.trim();
|
||||
if (q && !busy) ask(q);
|
||||
}
|
||||
});
|
||||
sendBtn.addEventListener('click', () => {
|
||||
const q = input.value.trim();
|
||||
if (q && !busy) ask(q);
|
||||
});
|
||||
|
||||
// 从 URL 读取问题(?q= 或 path 参数)
|
||||
let autoQ = initialQuestion || '';
|
||||
if (!autoQ) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
autoQ = (params.get('q') || '').trim();
|
||||
}
|
||||
if (autoQ) ask(autoQ);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user