Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- 模糊名词先澄清含义,再按所选方向学习。
- 公开网页提取标题、摘要、章节与正文要点。
- 围绕当前主题继续询问用法、示例和误区;百科主题优先依据已取得的来源摘录回答。
- 使用闪卡复习关键概念,并通过一道即时反馈的小测验巩固理解。
- 浮窗可以隐藏为右下角入口并随时恢复。

## 运行
Expand Down
133 changes: 132 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const state = {
current: null,
lastInput: "",
asking: false
asking: false,
study: { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null }
};

const $ = (selector, root = document) => root.querySelector(selector);
Expand Down Expand Up @@ -34,6 +35,7 @@ function showView(name) {
function reset() {
state.current = null;
state.lastInput = "";
resetStudy();
$("#mainInput").value = "";
$("#conversation").innerHTML = "";
showView("start");
Expand All @@ -47,6 +49,7 @@ async function analyze(input) {
return;
}
state.lastInput = value;
resetStudy();
$("#loadingTopic").textContent = value;
showView("loading");
try {
Expand Down Expand Up @@ -89,6 +92,11 @@ function renderSummary(result) {
? [["请用简单的话解释", "简单解释"], ["再讲详细一点", "再讲详细"], ["还有什么重要背景?", "重要背景"]]
: [["如何使用?", "如何使用?"], ["给我一个简单的例子", "给我例子"], ["有什么常见误区?", "常见误区"]];
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">
Expand All @@ -100,11 +108,106 @@ function renderSummary(result) {
<div class="quick-questions">
${quickQuestions}
</div>
${studyActions ? `<div class="study-actions">${studyActions}</div>` : ""}
</article>`;
showView("conversation");
icons();
}

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() {
$("#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();
}

async function ask(question) {
const value = question.trim();
if (!value || !state.current || state.asking) return;
Expand Down Expand Up @@ -157,9 +260,37 @@ 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]");
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();
}
});

$("#newButton").addEventListener("click", reset);
Expand Down
41 changes: 41 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,47 @@ input::placeholder { color: #9ca29b; }
.quick-questions { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 2px; }
.quick-questions button { height: 29px; padding: 0 10px; border: 1px solid #d7dbd4; border-radius: 15px; color: #666c66; background: white; font-size: 9px; }
.quick-questions button:hover { border-color: var(--blue); color: var(--blue); }
.study-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding-top: 4px; }
.study-action { min-width: 0; height: 36px; display: grid; grid-template-columns: 18px minmax(0, 1fr) 18px; align-items: center; gap: 6px; padding: 0 9px; border: 1px solid var(--line); border-radius: 6px; color: #555b55; background: var(--surface-2); font-size: 10px; text-align: left; }
.study-action:hover { border-color: var(--blue); color: var(--blue); background: white; }
.study-action svg { width: 14px; }
.study-action small { width: 18px; height: 18px; display: grid; place-items: center; border-radius: 4px; color: #59655a; background: #e2e6df; font-size: 8px; }
.study-panel { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; }
.study-panel-head { height: 45px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 10px 0 13px; border-bottom: 1px solid var(--line); }
.study-panel-head > div { min-width: 0; display: flex; align-items: center; gap: 7px; }
.study-panel-head > div > svg { width: 14px; color: var(--blue); }
.study-panel-head strong { font-size: 11px; }
.study-panel-head small { color: var(--muted); font-size: 8px; }
.study-icon-button { width: 30px; height: 30px; flex: 0 0 auto; display: grid; place-items: center; padding: 0; border: 0; border-radius: 5px; color: #747a73; background: transparent; }
.study-icon-button:hover:not(:disabled) { color: var(--ink); background: var(--surface-2); }
.study-icon-button:disabled { cursor: default; opacity: .3; }
.study-icon-button svg { width: 14px; }
.flashcard-content, .quiz-content { min-width: 0; padding: 17px 15px; }
.flashcard-content > span, .quiz-content > span, .flashcard-answer > span { display: block; margin-bottom: 7px; color: var(--blue); font-size: 8px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }
.flashcard-content h2, .quiz-content h2 { margin: 0; overflow-wrap: anywhere; font-size: 14px; line-height: 1.55; }
.flashcard-answer { min-height: 90px; display: grid; place-content: center; margin-top: 15px; padding-top: 14px; border-top: 1px solid var(--line); color: #9aa099; }
.flashcard-answer.is-visible { display: block; color: inherit; }
.flashcard-answer > svg { width: 19px; }
.flashcard-answer p { margin: 0; overflow-wrap: anywhere; color: #555b55; font-size: 10px; line-height: 1.7; }
.study-controls { height: 47px; display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; justify-items: center; gap: 8px; padding: 0 11px; border-top: 1px solid var(--line); }
.study-controls-single { grid-template-columns: 1fr; }
.reveal-button { height: 29px; padding: 0 12px; border: 1px solid #cfd4cc; border-radius: 5px; color: #525852; background: white; font-size: 9px; }
.reveal-button:hover { border-color: var(--blue); color: var(--blue); }
.quiz-options { display: grid; gap: 7px; margin-top: 14px; }
.quiz-option { min-width: 0; min-height: 42px; display: grid; grid-template-columns: 24px minmax(0, 1fr) 18px; align-items: center; gap: 8px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 6px; color: #555b55; background: white; text-align: left; }
.quiz-option:not(:disabled):hover { border-color: var(--blue); color: var(--blue); }
.quiz-option > span { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; color: #4f5750; background: var(--surface-2); font-size: 8px; font-weight: 700; }
.quiz-option strong { min-width: 0; overflow-wrap: anywhere; font-size: 10px; font-weight: 500; line-height: 1.45; }
.quiz-option small { width: 18px; height: 18px; display: grid; place-items: center; }
.quiz-option small svg { width: 13px; }
.quiz-option.is-correct { border-color: #80b38b; color: #397047; background: #eff8f1; }
.quiz-option.is-wrong { border-color: #e59a8d; color: #a54f41; background: #fff3f0; }
.quiz-option:disabled { cursor: default; }
.quiz-feedback { margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--line); }
.quiz-feedback strong { color: #3f7850; font-size: 10px; }
.quiz-feedback.is-wrong strong { color: #ad5546; }
.quiz-feedback p { margin: 5px 0 0; overflow-wrap: anywhere; color: #606660; font-size: 9px; line-height: 1.6; }
.study-action:focus-visible, .study-icon-button:focus-visible, .reveal-button:focus-visible, .quiz-option:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.user-message { justify-self: end; max-width: 82%; padding: 9px 12px; border-radius: 7px 7px 2px 7px; color: white; background: var(--ink); font-size: 11px; line-height: 1.55; }
.follow-answer { display: grid; gap: 8px; padding: 13px 14px; border: 1px solid var(--line); border-radius: 7px; background: white; }
.follow-answer strong { font-size: 12px; }
Expand Down
16 changes: 16 additions & 0 deletions test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ test("analyzes a precise term", async () => {
assert.ok(result.summary.length > 20);
assert.ok(result.concepts.length >= 3);
assert.ok(result.steps.length >= 3);
assert.equal(result.cards.length, 3);
assert.equal(result.quiz.options.length, 4);
assert.ok(result.quiz.answer >= 0 && result.quiz.answer < result.quiz.options.length);
});

test("serves the flashcard and quiz interactions", async () => {
const [scriptResponse, styleResponse] = await Promise.all([
fetch(`${baseUrl}/app.js`),
fetch(`${baseUrl}/styles.css`)
]);
assert.equal(scriptResponse.status, 200);
assert.equal(styleResponse.status, 200);
const [script, styles] = await Promise.all([scriptResponse.text(), styleResponse.text()]);
assert.match(script, /data-card-reveal/);
assert.match(script, /aria-label="打开闪卡,共/);
assert.match(styles, /\.quiz-option\.is-correct/);
});

test("asks for clarification when a term is ambiguous", async () => {
Expand Down