-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
412 lines (384 loc) · 18.3 KB
/
Copy pathapp.js
File metadata and controls
412 lines (384 loc) · 18.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
const state = {
current: null,
lastInput: "",
asking: false,
offerSubmitting: false,
study: { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null }
};
const $ = (selector, root = document) => root.querySelector(selector);
function icons() {
window.lucide?.createIcons({ attrs: { "stroke-width": 1.8 } });
}
function escapeHtml(value = "") {
return String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char]));
}
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => toast.classList.remove("show"), 1900);
}
function recordCommercialEvent(event) {
fetch("/api/commercial-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event }),
keepalive: true
}).catch(() => {});
}
function showView(name) {
$("#startView").hidden = name !== "start";
$("#loadingView").hidden = name !== "loading";
$("#clarifyView").hidden = name !== "clarify";
$("#conversationView").hidden = name !== "conversation";
$("#followUpForm").hidden = name !== "conversation";
$("#widgetBody").scrollTop = 0;
}
function reset() {
state.current = null;
state.lastInput = "";
state.offerSubmitting = false;
resetStudy();
$("#mainInput").value = "";
$("#conversation").innerHTML = "";
showView("start");
setTimeout(() => $("#mainInput").focus(), 80);
}
async function analyze(input) {
const value = input.trim();
if (!value) {
showToast("先输入一个网址或名词");
return;
}
state.lastInput = value;
resetStudy();
$("#loadingTopic").textContent = value;
showView("loading");
try {
const response = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: value })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "分析失败");
await new Promise((resolve) => setTimeout(resolve, 500));
if (result.needsClarification) renderClarification(result);
else {
state.current = result;
renderSummary(result);
}
} catch (error) {
showView("start");
showToast(error.message);
}
}
function renderClarification(result) {
$("#clarifyQuestion").textContent = result.question;
$("#clarifyOptions").innerHTML = result.options.map((option) => `
<button class="clarify-option" data-clarify="${escapeHtml(option)}">
<span>${escapeHtml(option)}</span><i data-lucide="arrow-up-right"></i>
</button>`).join("");
$("#clarifyInput").value = "";
showView("clarify");
icons();
}
function renderSummary(result) {
const sourceName = result.source ? [result.source.provider, result.source.license].filter(Boolean).join(" · ") || "原文" : "";
const source = result.source ? `<a class="source-button" href="${escapeHtml(result.source.url)}" target="_blank" rel="noreferrer" aria-label="打开来源:${escapeHtml(sourceName)}" title="${escapeHtml(sourceName)}"><i data-lucide="external-link"></i></a>` : "";
const concepts = result.concepts.slice(0, 3).map(([name, detail], index) => `<li data-index="${String(index + 1).padStart(2, "0")}"><span><strong>${escapeHtml(name)}</strong> · ${escapeHtml(detail)}</span></li>`).join("");
const steps = result.steps.slice(0, 3).map((step, index) => `<li><span>${index + 1}</span>${escapeHtml(step)}</li>`).join("");
const questions = result.source?.provider === "维基百科"
? [["请用简单的话解释", "简单解释"], ["再讲详细一点", "再讲详细"], ["还有什么重要背景?", "重要背景"]]
: [["如何使用?", "如何使用?"], ["给我一个简单的例子", "给我例子"], ["有什么常见误区?", "常见误区"]];
const quickQuestions = questions.map(([question, label]) => `<button data-question="${escapeHtml(question)}">${escapeHtml(label)}</button>`).join("");
const cardCount = validCards(result).length;
const studyActions = [
cardCount ? `<button class="study-action" data-study="cards" aria-label="打开闪卡,共 ${cardCount} 张"><i data-lucide="layers-3"></i><span>闪卡</span><small>${cardCount}</small></button>` : "",
validQuiz(result) ? `<button class="study-action" data-study="quiz" aria-label="打开测验,共 1 题"><i data-lucide="circle-help"></i><span>测一测</span><small>1</small></button>` : ""
].filter(Boolean).join("");
$("#conversation").innerHTML = `
<article class="assistant-message">
<div class="message-head"><span class="agent-avatar"><i data-lucide="sparkles"></i></span><span>已为你整理</span></div>
<div class="topic-line"><div><small>${escapeHtml(result.category)}</small><h1>${escapeHtml(result.title)}</h1></div>${source}</div>
<section class="summary-block"><span>30 秒总结</span><p>${escapeHtml(result.summary)}</p></section>
<section class="mini-section"><span>核心理解</span><h2>它是什么,为什么值得了解</h2><p>${escapeHtml(result.definition)}</p><ul class="concept-list">${concepts}</ul></section>
<section class="mini-section"><span>马上上手</span><ul class="step-list">${steps}</ul></section>
<div class="quick-questions">
${quickQuestions}
</div>
${studyActions ? `<div class="study-actions">${studyActions}</div>` : ""}
<section class="paid-offer" aria-label="定制学习包">
<div class="paid-offer-copy">
<span>首批定制</span>
<strong>把这个主题做成可执行学习包</strong>
<small>学习路线 · 10 张闪卡 · 5 道测验 · 1 个实战任务</small>
</div>
<button data-offer-open aria-label="定制学习包,29 元每份"><span>定制学习包</span><strong>¥29</strong><i data-lucide="arrow-up-right"></i></button>
</section>
</article>`;
showView("conversation");
icons();
recordCommercialEvent("offer_view");
}
function resetStudy() {
state.study = { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null };
}
function validCards(result = state.current) {
return Array.isArray(result?.cards)
? result.cards
.filter((card) => Array.isArray(card) && typeof card[0] === "string" && card[0].trim() && typeof card[1] === "string" && card[1].trim())
.map(([question, answer]) => [question.trim().slice(0, 300), answer.trim().slice(0, 600)])
.slice(0, 12)
: [];
}
function validQuiz(result = state.current) {
const quiz = result?.quiz;
const answer = quiz?.answer;
if (typeof quiz?.question !== "string" || !quiz.question.trim() || !Array.isArray(quiz.options)) return null;
if (quiz.options.length < 2 || quiz.options.length > 6 || quiz.options.some((option) => typeof option !== "string" || !option.trim())) return null;
const options = quiz.options.map((option) => option.trim());
if (!Number.isInteger(answer) || answer < 0 || answer >= options.length) return null;
return {
question: quiz.question.trim().slice(0, 400),
options: options.map((option) => option.slice(0, 240)),
answer,
explanation: typeof quiz.explanation === "string" ? quiz.explanation.slice(0, 600) : ""
};
}
function showStudy(mode) {
if (mode === "cards" && !validCards().length) return;
if (mode === "quiz" && !validQuiz()) return;
state.study = { mode, cardIndex: 0, cardRevealed: false, quizAnswer: null };
renderStudyPanel();
}
function renderStudyPanel() {
$("#offerPanel")?.remove();
$("#studyPanel")?.remove();
if (!state.study.mode) return;
const conversation = $("#conversation");
if (state.study.mode === "cards") {
const cards = validCards();
const index = Math.min(state.study.cardIndex, cards.length - 1);
const [question, answer] = cards[index];
const revealed = state.study.cardRevealed;
conversation.insertAdjacentHTML("beforeend", `
<section class="study-panel" id="studyPanel" aria-live="polite">
<header class="study-panel-head">
<div><i data-lucide="layers-3"></i><strong>闪卡</strong><small>${index + 1} / ${cards.length}</small></div>
<button class="study-icon-button" data-study-close aria-label="关闭闪卡" title="关闭"><i data-lucide="x"></i></button>
</header>
<div class="flashcard-content">
<span>问题</span>
<h2>${escapeHtml(question)}</h2>
<div class="flashcard-answer ${revealed ? "is-visible" : ""}">
${revealed ? `<span>答案</span><p>${escapeHtml(answer)}</p>` : `<i data-lucide="eye-off" aria-hidden="true"></i>`}
</div>
</div>
<footer class="study-controls">
<button class="study-icon-button" data-card-nav="-1" aria-label="上一张闪卡" title="上一张" ${index === 0 ? "disabled" : ""}><i data-lucide="arrow-left"></i></button>
<button class="reveal-button" data-card-reveal>${revealed ? "隐藏答案" : "显示答案"}</button>
<button class="study-icon-button" data-card-nav="1" aria-label="下一张闪卡" title="下一张" ${index === cards.length - 1 ? "disabled" : ""}><i data-lucide="arrow-right"></i></button>
</footer>
</section>`);
} else {
const quiz = validQuiz();
const answered = state.study.quizAnswer !== null;
const selected = state.study.quizAnswer;
const options = quiz.options.map((option, index) => {
const isCorrect = answered && index === quiz.answer;
const isWrong = answered && index === selected && index !== quiz.answer;
const status = isCorrect ? `<i data-lucide="check"></i>` : isWrong ? `<i data-lucide="x"></i>` : "";
return `<button class="quiz-option ${isCorrect ? "is-correct" : ""} ${isWrong ? "is-wrong" : ""}" data-quiz-option="${index}" ${answered ? "disabled" : ""}><span>${String.fromCharCode(65 + index)}</span><strong>${escapeHtml(option)}</strong><small>${status}</small></button>`;
}).join("");
const correct = selected === quiz.answer;
conversation.insertAdjacentHTML("beforeend", `
<section class="study-panel" id="studyPanel" aria-live="polite">
<header class="study-panel-head">
<div><i data-lucide="circle-help"></i><strong>测一测</strong><small>1 题</small></div>
<button class="study-icon-button" data-study-close aria-label="关闭测验" title="关闭"><i data-lucide="x"></i></button>
</header>
<div class="quiz-content">
<span>选择题</span>
<h2>${escapeHtml(quiz.question)}</h2>
<div class="quiz-options">${options}</div>
${answered ? `<div class="quiz-feedback ${correct ? "is-correct" : "is-wrong"}" role="status"><strong>${correct ? "回答正确" : "再想一步"}</strong>${quiz.explanation ? `<p>${escapeHtml(quiz.explanation)}</p>` : ""}</div>` : ""}
</div>
${answered ? `<footer class="study-controls study-controls-single"><button class="study-icon-button" data-quiz-reset aria-label="重新作答" title="重新作答"><i data-lucide="rotate-ccw"></i></button></footer>` : ""}
</section>`);
}
icons();
scrollToBottom();
}
function showOffer() {
if (!state.current) return;
recordCommercialEvent("offer_open");
resetStudy();
$("#studyPanel")?.remove();
$("#offerPanel")?.remove();
$("#conversation").insertAdjacentHTML("beforeend", `
<section class="offer-panel" id="offerPanel">
<header class="study-panel-head">
<div><i data-lucide="package-check"></i><strong>定制学习包</strong><small>¥29 / 份</small></div>
<button class="study-icon-button" data-offer-close aria-label="关闭定制学习包" title="关闭"><i data-lucide="x"></i></button>
</header>
<div class="offer-details">
<strong>24 小时内人工确认并交付</strong>
<ul><li>聚焦目标的学习路线</li><li>10 张闪卡与 5 道带解释的测验</li><li>1 个能真正上手的实战任务</li></ul>
</div>
<form class="offer-form" id="offerForm">
<label>学习主题<input name="topic" value="${escapeHtml(state.current.title)}" maxlength="160" required /></label>
<label>想达到什么目标?<textarea name="goal" minlength="5" maxlength="600" required placeholder="例如:两天内理解 MCP,并独立做出一个只读工具"></textarea></label>
<label>联系方式<input name="contact" maxlength="120" required autocomplete="email" placeholder="微信号、邮箱或手机号" /></label>
<label class="offer-honeypot" aria-hidden="true">网站<input name="website" tabindex="-1" autocomplete="off" /></label>
<label class="offer-consent"><input name="consent" type="checkbox" required /><span>同意仅用此联系方式确认本次需求和付款方式,不用于无关营销</span></label>
<button class="offer-submit" type="submit"><span>登记购买意向</span><strong>¥29</strong></button>
<p>提交只登记意向,不会自动扣款;确认需求后再决定是否付款。</p>
</form>
</section>`);
icons();
scrollToBottom();
}
async function submitOffer(form) {
if (state.offerSubmitting) return;
state.offerSubmitting = true;
const submit = form.querySelector("button[type='submit']");
submit.disabled = true;
submit.querySelector("span").textContent = "正在登记";
const data = new FormData(form);
try {
const response = await fetch("/api/paid-pack-interest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topic: data.get("topic"),
goal: data.get("goal"),
contact: data.get("contact"),
website: data.get("website"),
consent: data.get("consent") === "on"
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "暂时无法登记");
$("#offerPanel").innerHTML = `
<div class="offer-success" role="status">
<span><i data-lucide="check"></i></span>
<strong>购买意向已登记</strong>
<p>编号 ${escapeHtml(result.reference)}。我们会通过你留下的联系方式确认范围和付款方式。</p>
<small>本次没有自动扣款。</small>
<button data-offer-close>返回学习</button>
</div>`;
icons();
} catch (error) {
showToast(error.message);
submit.disabled = false;
submit.querySelector("span").textContent = "登记购买意向";
} finally {
state.offerSubmitting = false;
scrollToBottom();
}
}
async function ask(question) {
const value = question.trim();
if (!value || !state.current || state.asking) return;
state.asking = true;
$("#followUpInput").value = "";
const conversation = $("#conversation");
conversation.insertAdjacentHTML("beforeend", `<div class="user-message">${escapeHtml(value)}</div><div class="answer-loading" id="answerLoading"><span></span><span></span><span></span></div>`);
scrollToBottom();
try {
const response = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: value, context: state.current })
});
const answer = await response.json();
if (!response.ok) throw new Error(answer.error || "暂时无法回答");
const bullets = (answer.bullets || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
$("#answerLoading")?.remove();
conversation.insertAdjacentHTML("beforeend", `<article class="follow-answer"><strong>${escapeHtml(answer.title)}</strong><p>${escapeHtml(answer.text)}</p>${bullets ? `<ul class="answer-bullets">${bullets}</ul>` : ""}</article>`);
} catch (error) {
$("#answerLoading")?.remove();
showToast(error.message);
} finally {
state.asking = false;
scrollToBottom();
}
}
function scrollToBottom() {
requestAnimationFrame(() => { $("#widgetBody").scrollTop = $("#widgetBody").scrollHeight; });
}
$("#mainForm").addEventListener("submit", (event) => {
event.preventDefault();
analyze($("#mainInput").value);
});
$("#clarifyForm").addEventListener("submit", (event) => {
event.preventDefault();
const detail = $("#clarifyInput").value.trim();
if (detail) analyze(`${state.lastInput}(${detail})`);
});
$("#followUpForm").addEventListener("submit", (event) => {
event.preventDefault();
ask($("#followUpInput").value);
});
document.addEventListener("submit", (event) => {
if (!event.target.matches("#offerForm")) return;
event.preventDefault();
submitOffer(event.target);
});
document.addEventListener("click", (event) => {
const example = event.target.closest("[data-example]");
const clarify = event.target.closest("[data-clarify]");
const question = event.target.closest("[data-question]");
const study = event.target.closest("[data-study]");
const studyClose = event.target.closest("[data-study-close]");
const cardReveal = event.target.closest("[data-card-reveal]");
const cardNav = event.target.closest("[data-card-nav]");
const quizOption = event.target.closest("[data-quiz-option]");
const quizReset = event.target.closest("[data-quiz-reset]");
const offerOpen = event.target.closest("[data-offer-open]");
const offerClose = event.target.closest("[data-offer-close]");
if (example) analyze(example.dataset.example);
if (clarify) analyze(clarify.dataset.clarify);
if (question) ask(question.dataset.question);
if (study) showStudy(study.dataset.study);
if (studyClose) {
resetStudy();
$("#studyPanel")?.remove();
}
if (cardReveal) {
state.study.cardRevealed = !state.study.cardRevealed;
renderStudyPanel();
}
if (cardNav && !cardNav.disabled) {
state.study.cardIndex += Number(cardNav.dataset.cardNav);
state.study.cardRevealed = false;
renderStudyPanel();
}
if (quizOption && state.study.quizAnswer === null) {
state.study.quizAnswer = Number(quizOption.dataset.quizOption);
renderStudyPanel();
}
if (quizReset) {
state.study.quizAnswer = null;
renderStudyPanel();
}
if (offerOpen) showOffer();
if (offerClose) $("#offerPanel")?.remove();
});
$("#newButton").addEventListener("click", reset);
$("#minimizeButton").addEventListener("click", () => {
const widget = $("#learningWidget");
widget.classList.add("hiding");
setTimeout(() => {
widget.hidden = true;
widget.classList.remove("hiding");
$("#launcher").hidden = false;
}, 200);
});
$("#launcher").addEventListener("click", () => {
$("#launcher").hidden = true;
$("#learningWidget").hidden = false;
});
window.addEventListener("load", icons);