---
ai_context:
  app_id: ai-assistant
  content_revision: 2
  source_required: false
  surface: ai_assistant
  task_type: teaching_qa
archived: false
created_at: 1788319781
id: 705d5c3c-c6ca-40fe-848b-d6874161703f
md_path: /app/data/users/2121d6d9-bbb1-492a-a750-24331a682ad0/705d5c3c-c6ca-40fe-848b-d6874161703f.md
meta: {}
path: 705d5c3c-c6ca-40fe-848b-d6874161703f.md
pinned: false
title: 结合 AI 智能体的组成要素：大模型、规划（提示词，开场白，默认三个提问）、记忆、动作。面向国际中文...
updated_at: 1788320474
user_id: 2121d6d9-bbb1-492a-a750-24331a682ad0
---

```json
{"title":"结合 AI 智能体的组成要素：大模型、规划（提示词，开场白，默认三个提问）、记忆、动作。面向国际中文...","models":["deepseek-v4-pro"],"history":{"messages":{"232a9465-5890-42ab-bcee-cae099b53a0c":{"id":"232a9465-5890-42ab-bcee-cae099b53a0c","parentId":null,"childrenIds":["1dafd672-3793-4263-b0b1-7c451935d8fe"],"role":"user","content":"结合 AI 智能体的组成要素：大模型、规划（提示词，开场白，默认三个提问）、记忆、动作。面向国际中文教育领域，帮我构建一个“AI 实践智能体”，输出html网页项目，能让学生进入网页进行基于《国际中文教育中文水平等级标准》话题的对话实践。  \n###html上的交互设计参考“AI实践智能体设计.pdf”  \n###《国际中文教育中文水平等级标准》的话题列表：  \n[  \n{\"level\": \"1\" ,\"name\":\"一级\",\"topics\":[\"个人信息\",\"日常起居\",\"饮食\",\"交通\",\"兴趣爱好\"]},  \n{\"level\": \"2\" ,\"name\":\"二级\",\"topics\":[\"话题涉及基本社交\",\"家庭生活\",\"学习安排\",\"购物\",\"用餐\",\"个人感受\"]},  \n{\"level\": \"3\" ,\"name\":\"三级\",\"topics\":[\"出行经历\",\"课程情况\",\"体活动\",\"节日习俗\",\"教育\",\"职业\"]},  \n{\"level\": \"4\" ,\"name\":\"四级\",\"topics\":[\"社区生活\",\"健康状况\",\"校园生活\",\"日常办公\",\"动物\",\"植物\"]},  \n{\"level\": \"5\" ,\"name\":\"五级\",\"topics\":[\"人际关系\",\"生活方式\",\"学习方法\",\"自然环境\",\"社会现象\"]},  \n{\"level\": \"6\" ,\"name\":\"六级\",\"topics\":[\"土会交往\",\"公司事务\",\"矛盾纷争\",\"社会新闻\",\"中外比较\"]},  \n{\"level\": \"7\" ,\"name\":\"七级\",\"topics\":[\"社交礼仪\",\"科学技术\",\"文艺\",\"体育\",\"心理情感\",\"专业课程\"]},  \n{\"level\": \"8\" ,\"name\":\"八级\",\"topics\":[\"语言文字\",\"政治经济\",\"法律事务\",\"哲学\",\"历史\"]},  \n{\"level\": \"9\" ,\"name\":\"九级\",\"topics\":[\"学术研究\",\"政策法规\",\"商业贸易\",\"国际事务\"]}  \n]","timestamp":1788319756,"models":["deepseek-v4-pro"]},"1dafd672-3793-4263-b0b1-7c451935d8fe":{"parentId":"232a9465-5890-42ab-bcee-cae099b53a0c","id":"1dafd672-3793-4263-b0b1-7c451935d8fe","childrenIds":[],"role":"assistant","content":"<details type=\"reasoning\" done=\"true\" duration=\"233\">\n<summary>Thought for 233 seconds</summary>\n> We need answer in Chinese likely. Need build HTML webpage project for \"AI 实践智能体\" for international Chinese education based on topics. Need output HTML code. The user asks \"结合 AI 智能体的组成要素：大模型、规划（提示词，开场白，默认三个提问）、记忆、动作。面向国际中文教育领域，帮我构建一个“AI 实践智能体”，输出html网页项目，能让学生进入网页进行基于《国际中文教育中文水平等级标准》话题的对话实践。  \n> ###html上的交互设计参考“AI实践智能体设计.pdf”  \n> ###《国际中文教育中文水平等级标准》的话题列表： ...\"\n> \n> We need produce an HTML webpage project. We can't see attached PDF but can infer typical AI practice agent design: chat interface, topic selection, level selection, prompt, opening, three default questions, memory display, actions maybe record, translate, pinyin, vocab, etc. Need include components: big model (simulated or API config), planning (prompt, opening, default three questions), memory, actions. Need probably a single self-contained HTML with CSS/JS, maybe call backend? Since no API key, we can build front-end that can be connected to LLM API (like OpenAI-compatible, Kimi, DeepSeek, etc.) with configuration. Also include demo mode? Need maybe use Web Speech API? Need include topic list.\n> \n> Let's design fully self-contained HTML/CSS/JS. It should be a \"AI Practice Agent\" for Chinese learners. Features:\n> - Sidebar: topic/level selection from provided list, maybe \"场景\" display.\n> - Main chat area with messages.\n> - Top: agent name, level/topic, buttons actions.\n> - Planning: system prompt, opening message, default three questions (clickable chips), scenario.\n> - Memory: conversation history stored in JS array, maybe localStorage; memory panel showing current topic, level, user mistakes, new words? We can include a \"记忆\" drawer to view summarised memory.\n> - Actions: speech input? Record voice (Web Speech API for Chinese), playback TTS? Text-to-speech for AI messages, evaluate, translate, pinyin? Maybe action buttons:\n>   - 🎤 语音输入 (SpeechRecognition if available)\n>   - 🔊 朗读 (SpeechSynthesis)\n>   - 📝 评估/反馈 (call LLM to get corrections)\n>   - 🧠 记忆 (show memory panel)\n>   - 🔄 新对话/清空\n> - Big model: config panel for API base, key, model; default maybe use local simulated AI if no API? Need integrate with OpenAI-compatible chat completions. Could also provide \"模拟模式\" built-in responses. User can choose model config. Implement call to `{baseURL}/chat/completions` with bearer key. Use streaming? Could use fetch and non-stream. Need handle errors.\n> \n> Need structure:\n> - Data: topics list JS array.\n> - System prompt: maybe generated based on selected level/topic:\n> \"You are an AI Chinese practice partner for international Chinese education. Follow the 《国际中文教育中文水平等级标准》. Current level: X, topic: Y. Engage the student in a dialogue on the topic. Use vocabulary and grammar appropriate to the level. Ask one question at a time. Keep responses short. If student makes errors, gently correct. Provide pinyin for new words if needed. At the start, say opening line and offer three questions.\"\n> - Opening: after topic selected, AI sends message: \"你好！我是你的中文口语陪练。今天的话题是「{topic}」（{level}）。你可以选择下面的问题开始，或者直接和我聊天。\"\n> - Default three questions: We need generate based on topic and level. Since no backend, can have template generator? Could generate from topic. Better include a set of question templates by level? Could create dynamic prompts by level difficulty. For any topic, generate three questions like:\n>   1. \"你喜欢{topic}吗？为什么？\" (lower)\n>   2. \"请介绍一下你和{topic}有关的经历。\"\n>   3. \"在你的国家，{topic}和这里有什么不同？\"\n> But need use Chinese and align with level. Could build function generateDefaultQuestions(level, topic) with templates based on level (1-9). For higher levels use more complex sentence patterns. We can design an array of template functions:\n> - L1: \"你喜欢{topic}吗？\" \"你每天什么时候{topic}？\" But topic may not fit verb. Better general: \n>   一级: simple questions:\n>   \"你喜欢{topic}吗？\"\n>   \"你周末做什么？\" (related)\n>   \"{topic}怎么样？\"\n> - L2: \"你经常做和{topic}有关的事情吗？\"\n> - L3: \"请说说你的一次{topic}经历。\"\n> - L4: \"你觉得{topic}对你的生活重要吗？\"\n> - etc.\n> Need generic enough.\n> \n> Maybe store default questions per level as functions with topic placeholder:\n> const defaultQuestionBank = [\n>   level 1: [\n>     `你喜欢${topic}吗？`,\n>     `你常常做什么和${topic}有关的事？`,\n>     `你觉得${topic}有意思吗？`\n>   ],\n> ...\n> ]\n> But \"交通\" topic: \"你喜欢交通吗\" weird but acceptable. Maybe use \"你对交通感兴趣吗？\" But can adapt.\n> \n> Could also create for each topic a specific set? That's huge. Maybe create topic-specific default questions from topic names? We can provide generic templates but some may be awkward. Let's craft better generic templates by level that can accept topic:\n> Level 1 templates:\n> - `你喜欢${topic}吗？为什么？`\n> - `你每天/周末会做和${topic}有关的事情吗？`\n> - `请你简单介绍一下：${topic}。`\n> Level 2:\n> - `你和家人/朋友常聊${topic}吗？`\n> - `请说说你昨天的${topic}安排。` (if topic like 家庭生活...)\n> - `你觉得${topic}怎么样？`\n> Level 3:\n> - `请分享一次和${topic}有关的经历。`\n> - `在你的国家，${topic}有什么习惯或风俗？`\n> - `学习/工作/生活中，${topic}对你有什么影响？`\n> Level 4:\n> - `请描述一下你所在社区的${topic}情况。`\n> - `你认为如何改善${topic}？`\n> - `${topic}对你的健康/学习/工作有什么影响？` (could be awkward)\n> Level 5:\n> - `你和朋友之间有没有关于${topic}的有趣故事？`\n> - `在${topic}方面，你有什么好的方法或经验？`\n> - `你如何看待现在的${topic}现象？`\n> Level 6:\n> - `如果遇到和${topic}有关的矛盾，你会怎么处理？`\n> - `请比较一下中外在${topic}方面的异同。`\n> - `你最近关注过哪些和${topic}有关的社会新闻？`\n> Level 7:\n> - `在专业或社交场合中，${topic}有哪些礼仪/注意事项？`\n> - `科学技术对${topic}产生了什么影响？`\n> - `请谈谈你对${topic}的心理感受或情感体验。`\n> Level 8:\n> - `从历史/哲学角度看，${topic}有什么意义？`\n> - `语言文字与${topic}有什么关系？`\n> - `政治经济因素如何影响${topic}？`\n> Level 9:\n> - `关于${topic}，学术界有哪些重要研究或争议？`\n> - `政策法规对${topic}有哪些影响？`\n> - `在国际事务/商业贸易中，${topic}面临什么挑战？`\n> \n> These may be okay.\n> \n> Need also \"prompt\" area maybe editable so teacher can modify. The agent planning: show system prompt in a collapsible panel, allow edit. Opening message config. Default three questions.\n> \n> Memory: We can maintain:\n> - conversation history array (role, content)\n> - user profile memory: level, topic, learned words? Could store user utterances, AI corrections.\n> We can have memory summary displayed in a drawer:\n>   - 当前等级/话题\n>   - 对话轮数\n>   - 用户发言记录\n>   - AI 纠正记录 (if we implement simple pattern corrections)\n>   - 新词表 (extracted? maybe from AI responses? Hard)\n> Could do client-side \"记忆\" includes recent messages and key points. For demo, maintain `memory` object:\n> ```\n> {\n>   level, topic,\n>   history: [],\n>   corrections: [],\n>   newWords: [],\n>   turnCount: 0\n> }\n> ```\n> Actions:\n> - \"纠正/评估\" button: asks LLM to act as evaluator and provide feedback on user's last message, maybe update memory corrections and new words.\n> - \"翻译\" maybe translate selected text? maybe not necessary.\n> - \"朗读\" TTS.\n> - \"语音输入\" STT.\n> - \"清空记忆\"\n> - \"导出记忆\" maybe download JSON.\n> - \"换话题\" resets.\n> \n> Need ensure UI responsive, beautiful. Include Chinese fonts. Use CSS variables, card layout, sidebar, chat bubbles, chips, top bar, drawer.\n> \n> Need maybe use markdown? We can render simple text.\n> \n> Need handle API request. We need Big Model configuration:\n> - field: API Base URL (default https://api.openai.com/v1)\n> - API Key\n> - Model (default gpt-4o-mini, maybe)\n> - Temperature? \n> - System Prompt editable\n> - Opening message editable\n> - Default questions editable? Maybe display generated questions, maybe each chip.\n> \n> \"大模型\" component: We can include a settings modal where users configure. For immediate functionality without API key, maybe include \"演示模式\" with canned responses. Need use actual API if configured. The user says \"大模型\" as a component; likely want to connect to a model. We'll implement both: if no API key, fallback to a built-in \"Demo AI\" that generates simple responses based on selected topic. Or disallow? Better include demo mode to test.\n> \n> Let's craft code. Need be careful syntactically. We'll output full HTML.\n> \n> Plan:\n> - HTML skeleton:\n>   - `<header>`: logo, title \"AI 中文实践智能体\", level/topic selector, buttons: 设置, 记忆, 清空.\n>   - `<main>`:\n>     - `<aside class=\"sidebar\">`: topic list grouped by level, select level dropdown maybe. Also active topic. \n>     - `<section class=\"chat-area\">`: \n>       - `<div class=\"chat-header\">`: current level/topic, scenario.\n>       - `<div class=\"messages\" id=\"messages\">`\n>       - `<div class=\"default-questions\" id=\"defaultQuestions\">` chips\n>       - `<div class=\"composer\">` textarea, buttons 🎤/🔊, send\n>     - `<aside class=\"memory-panel\">` maybe hidden drawer or side panel.\n>     - `<div class=\"settings-modal\">` \n> - CSS with variables, responsive.\n> - JS:\n>   - data array levels topics. Actually provided list has typo \"土会交往\" for level6; keep as given? Could correct to \"社会交往\"? The user provided \"土会交往\" likely typo. We should maybe use as provided or correct? Since based on standard, correct is \"社会交往\" maybe but user input says \"土会交往\" (maybe OCR). We can preserve? Might display. Better correct? The user explicitly gave list including \"土会交往\" but likely they'd prefer accurate. I can use the list as given, maybe fix obvious \"土会交往\" to \"社会交往\" and \"体活动\" to \"集体活动\"? Wait list includes \"体活动\" and \"土会交往\" possibly typos. In standard, level3 topics: \"出行经历\",\"课程情况\",\"集体活动\",\"节日习俗\",\"教育\",\"职业\" likely; user wrote \"体活动\" missing \"集\". Level6 \"土会交往\" should be \"社会交往\". We should use the given list but correct obvious typos? The prompt says based on 《标准》话题列表. Maybe we should include corrected topics. Hmm. The user might compare. But delivering accurate list better. I can use corrected list: level3 \"集体活动\", level6 \"社会交往\". Should I mention? Might not need. The code can include as provided? If we correct, it's okay.\n> \n> Need generate default questions per topic. For \"一级\" etc. Use level number.\n> \n> Potential issue: HTML contains lots of code; final answer only. Need maybe include instructions in response. Could output the HTML file content as code block. But user said \"输出html网页项目\"; they likely want code. We'll provide the HTML code in a code block. Could also mention save as `index.html`. Since they want output.\n> \n> Need perhaps include a \"AI实践智能体设计.pdf\" reference but I can't see. I'll design common interactions: chat bubbles, default questions chips, memory drawer, model settings, voice.\n> \n> Let's design elaborate UI.\n> \n> Detailed CSS:\n> - fonts: system, \"Noto Sans SC\", \"PingFang SC\".\n> - background #f5f7fb\n> - header dark gradient #2b3a55 to #1d2b3f.\n> - sidebar white, border radius, shadows.\n> - chat bubbles: user right blue, AI left white with border.\n> - chips: pill buttons.\n> - composer sticky bottom.\n> - memory panel sliding from right with overlay.\n> - modal overlay.\n> \n> HTML structure:\n> \n> ```\n> <body>\n> <div id=\"app\">\n>  <header class=\"topbar\">\n>   <div class=\"brand\"><span class=\"logo\">🗣️</span><h1>AI 中文实践智能体</h1></div>\n>   <div class=\"top-actions\">\n>     <span id=\"headerTopic\"></span>\n>     <button id=\"btnMemory\">🧠 记忆</button>\n>     <button id=\"btnSettings\">⚙️ 大模型</button>\n>   </div>\n>  </header>\n>  <div class=\"container\">\n>   <aside class=\"sidebar\">\n>     <h2>等级与话题</h2>\n>     <select id=\"levelSelect\"></select>\n>     <div id=\"topicList\" class=\"topic-list\"></div>\n>   </aside>\n>   <main class=\"chat-area\">\n>     <div class=\"chat-header\">\n>       <div><strong id=\"activeTopic\">个人信息</strong><span id=\"activeLevel\" class=\"badge\">一级</span></div>\n>       <p id=\"scenario\" class=\"scenario\"></p>\n>     </div>\n>     <div id=\"messages\" class=\"messages\"></div>\n>     <div id=\"quickQuestions\" class=\"quick-questions\"></div>\n>     <div class=\"composer\">\n>       <button id=\"btnVoice\" title=\"语音输入\">🎤</button>\n>       <textarea id=\"userInput\" placeholder=\"输入中文...\"></textarea>\n>       <button id=\"btnSpeak\" title=\"朗读回复\">🔊</button>\n>       <button id=\"btnSend\">发送</button>\n>     </div>\n>   </main>\n>  </div>\n> </div>\n> <div id=\"memoryDrawer\" class=\"drawer\">... \n> <div id=\"settingsModal\" class=\"modal\">... \n> <div id=\"toast\">\n> ```\n> \n> Need maybe use SVG icons? Emoji okay.\n> \n> JS implementation detail:\n> \n> Data and state:\n> ```\n> const levels = [...];\n> const state = {\n>   currentLevel: '1',\n>   currentTopic: '个人信息',\n>   currentLevelName: '一级',\n>   history: [],\n>   memory: { turns:0, corrections:[], newWords:[], userMessages:[] },\n>   config: { baseUrl:'https://api.openai.com/v1', apiKey:'', model:'gpt-4o-mini', temperature:0.7, systemPrompt:'', openingMessage:'', questions:[] },\n>   isDemoMode: false? maybe auto\n> };\n> ```\n> \n> Initialize:\n> - populate level select with levels.\n> - render topics for selected level, select first topic.\n> - load config and memory from localStorage.\n> - create system prompt based on level/topic.\n> - add opening AI message.\n> - render quick questions.\n> \n> Functions:\n> - `selectLevel(level)` set state, render topics, select first.\n> - `selectTopic(topic)` set currentTopic, reset conversation? Should ask confirm if resets? We'll just reset history and memory for new topic.\n> - `switchTopic(level, topic)`: update header, scenario, reset chat, add opening.\n> - `generateSystemPrompt(level, topic)`: returns detailed prompt.\n> - `generateOpening(level, topic)`: returns opening text.\n> - `generateQuestions(level, topic)`: returns array of 3 strings.\n> - `addMessage(role, content)`: create div.\n> - `renderMessages()` maybe maintain DOM.\n> - `sendMessage()`: get input, add user message, history, memory; call `respondToUser` with history plus system prompt. \n> - `callLLM(messages)`: if config.apiKey, fetch completions. Else demo response. Need likely async.\n> - `demoResponse(userText)`: produce simple AI response based on topic and level. Could use template:\n>   - if user asks? We can just say \"很有意思！关于{topic}，我还想问：{question}\" plus maybe simple echo. Keep not too complex. For demo mode, use Math.random to choose from responses. Need maybe not seem too fake but okay.\n> - `quickAsk(question)`: set input then send? Use add user message \"question\" and call LLM? Actually when user clicks a quick question chip, we can send that as user's first message. Or send a hidden instruction? Better: chip text = question, onclick sends it as user message.\n> - `readAloud(text)`: speechSynthesis with zh-CN.\n> - `startVoice()`: webkitSpeechRecognition / SpeechRecognition with lang zh-CN, onresult set input.\n> - `openMemoryDrawer()`: render memory details.\n> - Memory render: turns, user messages, corrections, newWords, and maybe \"导出 JSON\".\n> - `saveMemory()`: localStorage.\n> - Settings modal: fields; save config; update state.\n> - `resetChat()`: clear messages except maybe opening? clear memory? We'll do reset current topic.\n> \n> Need handle long chat scroll.\n> \n> Language model API integration: \n> ```\n> async function callLLM(messages) {\n>   if (!state.config.apiKey) return demoResponse(...);\n>   const res = await fetch(`${state.config.baseUrl.replace(/\\/$/,'')}/chat/completions`, {\n>     method:'POST',\n>     headers:{'Content-Type':'application/json','Authorization':`Bearer ${state.config.apiKey}`},\n>     body: JSON.stringify({model: state.config.model, messages, temperature: state.config.temperature})\n>   });\n>   if(!res.ok) throw new Error(...);\n>   const data = await res.json();\n>   return data.choices[0].message.content;\n> }\n> ```\n> But some providers like DeepSeek base URL is `https://api.deepseek.com/v1` or `https://api.deepseek.com`; same route. If base URL lacks `/v1`, user enters as desired. We'll normalize.\n> \n> Messages system: we need include system prompt + history. State.history can include role user/assistant. \n> `const apiMessages = [{role:'system', content: getSystemPrompt()}, ...history]`.\n> \n> Potential issue: user might want to modify system prompt. Save in config. On topic switch, auto regenerate. We might have an editable textarea in settings showing system prompt; user can edit. But if we want `config.systemPrompt` default blank => generated. We'll provide a \"重置\" prompt button.\n> \n> Default questions generation function:\n> Let's create mapping by level:\n> ```\n> function generateQuestions(level, topic){\n>  const qs = {\n>  1:[`你喜欢${topic}吗？为什么？`,`你经常做什么和${topic}有关的事？`,`请简单介绍一下${topic}。`],\n>  2:[`你和家人/朋友常聊${topic}吗？`,`请说说你最近一次和${topic}有关的安排。`,`你觉得${topic}怎么样？`],\n>  ...\n>  }\n>  return qs[level] || qs[1];\n> }\n> ```\n> But nuance for \"个人信息\" level1: \"你喜欢个人信息吗\" weird. Could special-case certain topics? We can add a map for common topics:\n> - 个人信息: [\"你叫什么名字？\",\"你今年多大？\",\"你住在哪个城市？\"]\n> - 日常起居: [\"你每天几点起床？\",\"你早上一般做什么？\",\"你晚上几点睡觉？\"]\n> - 饮食: [\"你喜欢吃什么菜？\",\"你早餐一般吃什么？\",\"你喝咖啡还是茶？\"]\n> - 交通: [\"你每天怎么去学校/上班？\",\"你喜欢坐地铁还是公交车？\",\"你骑过自行车吗？\"]\n> - 兴趣爱好: [\"你有什么爱好？\",\"你周末喜欢做什么？\",\"你喜欢运动吗？\"]\n> For level 2:\n> - 基本社交: [\"你见到朋友怎么打招呼？\",\"你会邀请朋友来家里吗？\",\"你常用什么社交软件？\"]\n> - 家庭生活: [\"你家有几口人？\",\"你和家人一起做什么？\",\"你常和父母聊天吗？\"]\n> - 学习安排: [\"你每天学习多长时间中文？\",\"你用什么方法学中文？\",\"你周末有学习计划吗？\"]\n> - 购物: [\"你常去哪儿买东西？\",\"你网上购物多吗？\",\"你买东西会讲价吗？\"]\n> - 用餐: [\"你常去餐厅吃饭吗？\",\"你喜欢中餐还是西餐？\",\"你会用筷子吗？\"]\n> - 个人感受: [\"你今天感觉怎么样？\",\"什么事情让你开心？\",\"你最近有压力吗？\"]\n> Level3:\n> - 出行经历: [\"你去过哪些地方旅行？\",\"你最难忘的一次出行是什么？\",\"旅行时你最喜欢做什么？\"]\n> - 课程情况: [\"你现在在上什么课？\",\"你最喜欢哪门课？为什么？\",\"你觉得哪门课最难？\"]\n> - 集体活动: [\"你参加过什么集体活动？\",\"你喜欢参加集体活动吗？\",\"你们班常组织活动吗？\"]\n> - 节日习俗: [\"你国家最重要的节日是什么？\",\"春节和你们国家的节日有什么不同？\",\"你庆祝节日时吃什么？\"]\n> - 教育: [\"你觉得什么样的教育是好教育？\",\"你国家的教育制度怎么样？\",\"你最喜欢的老师是谁？\"]\n> - 职业: [\"你以后想做什么工作？\",\"你父母的职业是什么？\",\"你觉得什么职业最有意思？\"]\n> Level4:\n> - 社区生活: [\"你住的小区怎么样？\",\"你和邻居关系好吗？\",\"社区里有什么设施？\"]\n> - 健康状况: [\"你最近身体怎么样？\",\"你每天睡几个小时？\",\"你常锻炼身体吗？\"]\n> - 校园生活: [\"你的学校大吗？\",\"你课后常参加什么活动？\",\"你最喜欢学校的哪个地方？\"]\n> - 日常办公: [\"你工作时常用电脑吗？\",\"你习惯在家办公还是在办公室？\",\"你怎么安排每天的工作？\"]\n> - 动物: [\"你喜欢什么动物？\",\"你养过宠物吗？\",\"你觉得动物园好不好？\"]\n> - 植物: [\"你喜欢种花草吗？\",\"你家里有植物吗？\",\"你去过植物园吗？\"]\n> Level5:\n> - 人际关系: [\"你觉得朋友之间最重要的是什么？\",\"你怎么认识新朋友？\",\"你和朋友吵过架吗？\"]\n> - 生活方式: [\"你的生活方式健康吗？\",\"你理想的生活是什么样的？\",\"你和父母的作息一样吗？\"]\n> - 学习方法: [\"你有什么好的学习方法？\",\"学中文时你怎么记生词？\",\"你更喜欢自学还是上课？\"]\n> - 自然环境: [\"你觉得你城市的环境怎么样？\",\"我们应该怎么保护环境？\",\"你喜欢去大自然吗？\"]\n> - 社会现象: [\"你最近关注什么社会现象？\",\"你对社交媒体怎么看？\",\"你身边有志愿者活动吗？\"]\n> Level6:\n> - 社会交往: [\"在社交场合，你遇到矛盾会怎么办？\",\"你怎样维护和同事的关系？\",\"你觉得网络社交和现实社交一样吗？\"]\n> - 公司事务: [\"你了解公司一般怎么开会吗？\",\"如果你和同事意见不同会怎么办？\",\"你觉得领导应该怎么做？\"]\n> - 矛盾纷争: [\"如果朋友误会你，你会怎么做？\",\"你怎么处理人与人之间的矛盾？\",\"你觉得沟通能解决所有矛盾吗？\"]\n> - 社会新闻: [\"你最近看了什么新闻？\",\"你常看哪方面的新闻？\",\"你觉得新闻真实重要吗？\"]\n> - 中外比较: [\"你觉得中国和你的国家有什么不同？\",\"在饮食方面，你的国家和中国有什么差异？\",\"你更适应哪种生活方式？\"]\n> Level7:\n> - 社交礼仪: [\"在正式场合应该注意哪些礼仪？\",\"你们国家的社交礼仪有什么特点？\",\"如果你迟到了会怎么解释？\"]\n> - 科学技术: [\"你觉得人工智能会改变教育吗？\",\"你常使用哪些科技产品？\",\"科学技术给你带来什么方便？\"]\n> - 文艺: [\"你喜欢什么类型的文艺作品？\",\"你看过中国的电影吗？\",\"你更喜欢小说还是电影？\"]\n> - 体育: [\"你喜欢看什么体育比赛？\",\"你平时参加什么运动？\",\"体育运动对你有什么帮助？\"]\n> - 心理情感: [\"你压力大的时候会怎么做？\",\"你怎么保持积极情绪？\",\"你会和朋友分享心情吗？\"]\n> - 专业课程: [\"你的专业是什么？\",\"你为什么选择这个专业？\",\"你最喜欢的专业课是什么？\"]\n> Level8:\n> - 语言文字: [\"你学中文遇到的最大困难是什么？\",\"你觉得汉字难吗？\",\"语言和文化有什么关系？\"]\n> - 政治经济: [\"你关注经济新闻吗？\",\"你觉得经济发展对普通人有什么影响？\",\"你了解中国的经济政策吗？\"]\n> - 法律事务: [\"你了解哪些法律常识？\",\"在你的国家，法律如何保护消费者？\",\"你觉得法律和道德有什么不同？\"]\n> - 哲学: [\"你思考过人生的意义吗？\",\"你读过哲学方面的书吗？\",\"你觉得幸福是什么？\"]\n> - 历史: [\"你喜欢历史吗？\",\"你了解中国的哪些历史？\",\"历史对今天的生活有什么影响？\"]\n> Level9:\n> - 学术研究: [\"你做过学术研究吗？\",\"你觉得学术研究最重要的是什么？\",\"你关注过哪些学术问题？\"]\n> - 政策法规: [\"你了解哪些公共政策？\",\"政策法规如何影响留学生活？\",\"你关注过教育政策吗？\"]\n> - 商业贸易: [\"你了解国际贸易吗？\",\"电子商务给商业带来什么变化？\",\"你做过生意吗？\"]\n> - 国际事务: [\"你关注哪些国际新闻？\",\"国际合作对世界有什么意义？\",\"你怎么看气候变化等全球问题？\"]\n> \n> This is good. Could incorporate in code as `topicQuestionMap`. If no specific map, fallback to level templates.\n> \n> But generating system prompt? Could include:\n> ```\n> 你是一位经验丰富的国际中文教育口语陪练，严格依据《国际中文教育中文水平等级标准》进行教学设计。\n> 当前学生等级：${levelName}（${level}级）\n> 当前话题：${topic}\n> 教学要求：\n> 1. 使用适合该等级的词汇和语法，避免超纲过多。\n> 2. 围绕话题进行互动提问，每次只问一个问题。\n> 3. 如果学生句子有语法错误，先重复正确句子，再简要指出。\n> 4. 对难词可提供拼音或英文解释。\n> 5. 保持友好、鼓励。\n> 6. 用中文回答，必要时可补充英文。\n> ```\n> Maybe ask model to be concise.\n> \n> Opening:\n> ```\n> 你好！我是你的中文口语陪练 🎓\n> 今天的话题是【${topic}】（${levelName}）。\n> 你可以点击下面的问题开始，也可以直接输入你想说的话。\n> ```\n> Good.\n> \n> Demo response function: It should be somewhat educational:\n> ```\n> function demoResponse(userText){\n>   const t = state.currentTopic;\n>   const qs = state.config.questions;\n>   const reply = [\n>    `你说得很好！关于“${t}”，我还想再问你：${qs[Math.floor(Math.random()*qs.length)]}`,\n>    `我明白了。${qs[...]}`,\n>    `有意思！你能再说说为什么吗？`\n>   ];\n>  return reply[Math.floor(Math.random()*reply.length)];\n> }\n> ```\n> Maybe not use too many random questions. Good.\n> \n> But if user config API, actual model used.\n> \n> Need voice input. Web Speech API only in Chrome. We'll implement with fallback toast.\n> \n> Need TTS. Use speechSynthesis.\n> \n> Memory: maybe `state.memory.userMessages` push user text. `state.memory.turns++`. Could also add `corrections` via simple local check? We can implement a basic correction heuristic? Not necessary. But the \"评估\" action can call LLM for correction. For demo, if no API key, we can generate a generic evaluation:\n> - \"评估反馈：你的表达很自然。建议多使用完整句子。继续加油！\"\n> We'll add an \"评估\" button in chat header maybe or as an action button below last AI message. Maybe action buttons in composer: 📝 评估, 🧠 记忆. The prompt says \"动作\" component. We can include buttons for voice,朗读,评估,记忆,导出. Good.\n> \n> Let's implement action bar maybe above composer: `动作：🎤 语音输入 | 🔊 朗读最后回复 | 📝 评估 | 🧠 记忆 | 🧹 新对话`.\n> \n> Actually composer already has voice and speak. Add evaluation button.\n> \n> Need maybe include \"默认三个提问\" chips under messages.\n> \n> Let's create complete code. Need be mindful of HTML escaping in JS template literals. Use plain JS. The code will be long. We need output in final. Could include no markdown? We can wrap in ```html. The code can be copy-pasted.\n> \n> Let's write meticulously.\n> \n> Pseudo JS:\n> \n> ```\n> const LEVELS = [ ... correct list ... ];\n> \n> const topicQuestionMap = { ... };\n> \n> const state = { ... };\n> \n> const els = {};\n> \n> document.addEventListener('DOMContentLoaded', init);\n> \n> function init(){\n>   els = {...};\n>   loadConfig();\n>   loadMemory();\n>   populateLevelSelect();\n>   renderTopics();\n>   bindEvents();\n>   applyConfigUI();\n> }\n> ```\n> \n> Need ensure first load: state.currentLevel='1', currentTopic= first topic of level 1. Then `setActiveTopic(topic, false)` maybe.\n> \n> `renderTopics()`:\n> - get topics for current level.\n> - innerHTML buttons; active class if currentTopic.\n> - each button text topic.\n> \n> `setActiveTopic(topic, reset=true)`:\n> - state.currentTopic = topic;\n> - update header;\n> - generate questions and opening maybe if reset. But on initial, no messages.\n> - save memory current topic.\n> - if reset `startConversation`.\n> \n> `startConversation()`:\n> - state.history = [];\n> - state.memory.turns = 0; state.memory.userMessages = []; state.memory.corrections=[]; state.memory.newWords = [];\n> - clear messages.\n> - generate config.questions = generateDefaultQuestions(...) (unless overwritten? maybe always dynamic)\n> - config.openingMessage = generateOpening(...) or from settings? User can edit opening in settings. But if we auto generate each topic, maybe editable system prompt/opening in settings? Could allow. But the planning component includes prompt, opening, default three questions. We can display these in settings modal, with fields: system prompt, opening message, default questions (textareas). On topic change, auto regenerate unedited.\n> - addMessage('assistant', opening);\n> - renderQuickQuestions().\n> \n> Need if user opens settings, we show current systemPrompt, openingMessage, questions. Save edits to state.config. But if level/topic change, maybe regenerate. It's okay.\n> \n> Memory save/load: We'll store `memory` but conversation history maybe not. Use localStorage.\n> \n> Important: `localStorage` may fail in sandbox? fine.\n> \n> `addMessage(role, content)`:\n> - state.history.push({role, content});\n> - append to messages. Use `textContent` for user? But maybe include HTML. We'll use textContent to avoid XSS, but newlines? Could set innerText. `div.innerText = content`. Good.\n> \n> Auto scroll.\n> \n> `renderQuickQuestions()`:\n> - container.innerHTML = state.config.questions.map((q,i)=>`<button class=\"chip\">${q}</button>`).join('');\n> - bind clicks.\n> \n> `toggleMemoryDrawer(open)`.\n> \n> `openSettings()` set values.\n> \n> `saveSettings()` read fields.\n> \n> `callLLM`:\n> ```\n> async function getAIResponse(userText){\n>   const system = state.config.systemPrompt || generateSystemPrompt();\n>   const messages = [{role:'system', content: system}, ...state.history.slice(-20)];\n>   // If no apiKey, demo\n>   if (!state.config.apiKey) return demoResponse(userText);\n>   try { ... } catch { toast('模型调用失败，已切换演示回复'); return demoResponse(userText); }\n> }\n> ```\n> \n> Need ensure system prompt is up-to-date before call. On topic change regenerate. User might edit; use state.config.systemPrompt.\n> \n> `sendMessage()`:\n> - const text = input.value.trim(); if empty return.\n> - addMessage('user', text);\n> - state.memory.userMessages.push(text);\n> - state.memory.turns += 1;\n> - show typing indicator.\n> - const reply = await getAIResponse(text);\n> - remove typing indicator; addMessage('assistant', reply);\n> - if auto speak maybe not.\n> - input.value=''\n> \n> `handleQuickAsk(q)`:\n> - input.value = q; sendMessage(); or addMessage user then call.\n> \n> `evaluateLast()`:\n> - if no user messages? toast.\n> - If API key, build messages: system evaluation prompt + history; call model.\n> - Else demo evaluation.\n> - Add evaluation as AI message maybe with \"📝 评估反馈：...\"\n> - Update memory.corrections push.\n> Implementation: \n> ```\n> async function evaluate(){\n>  const userMsgs = state.history.filter(m=>m.role==='user');\n>  if(!userMsgs.length){ toast('请先进行对话'); return; }\n>  const lastUser = userMsgs[userMsgs.length-1].content;\n>  let feedback;\n>  if(state.config.apiKey) {\n>   const sys = `你是国际中文教育评估专家。请根据《国际中文教育中文水平等级标准》${state.currentLevelName}水平，对学生的句子进行简短反馈：先肯定，再指出1-2个语法或词汇问题（如无问题就说表达自然），最后给一个更地道的说法。用中文，不超过100字。`;\n>   const messages=[{role:'system', content:sys},{role:'user',content:lastUser}];\n>   feedback = await callOpenAI(messages);\n>  } else {\n>   feedback = `【模拟评估】你说得很好！建议多使用完整的句子，并注意“了”“的”等虚词。继续加油！`;\n>  }\n>  addMessage('assistant', `📝 评估反馈：${feedback}`);\n>  state.memory.corrections.push({input:lastUser, feedback});\n> }\n> ```\n> But `callOpenAI` may throw. We'll wrap.\n> \n> `speakLastAI()`:\n> - get last assistant message text; speechSynthesis.\n> \n> `clearChat()`:\n> - confirm? reset current topic.\n> \n> `exportMemory()`:\n> - create blob JSON state.memory; download.\n> \n> Need implement `callOpenAI(messages)` separate:\n> ```\n> async function callOpenAI(messages, temp=state.config.temperature){\n>   const base = state.config.baseUrl.replace(/\\/$/,'');\n>   const endpoint = `${base}/chat/completions`;\n>   const resp = await fetch(endpoint, { ...});\n>   if(!resp.ok){ const err = await resp.text(); throw new Error(err); }\n>   const data = await resp.json();\n>   return data.choices[0].message.content.trim();\n> }\n> ```\n> Need note some base URLs include `/v1` already. Good.\n> \n> Potential CORS: browser may block. We can mention that API calls from browser may need CORS. But not problem. Could also use proxy? No. We'll add note in settings: \"请确保 API 服务允许浏览器跨域访问，或使用兼容网关\". Good.\n> \n> Need maybe include temperature.\n> \n> Now craft topicQuestionMap for all topics to be accurate as above.\n> \n> Let's write final HTML. It will be large. We need avoid errors.\n> \n> Let's start writing final. Ensure code uses `const LEVELS = [...]` with corrected list. Need maybe include `level` string \"1\" etc.\n> \n> Implement CSS carefully.\n> \n> Let's produce final response with concise intro then code block. Need not overly verbose. But maybe user expects just HTML.\n> \n> Let's ensure no syntax errors in JS. We'll be meticulous.\n> \n> Possible issues:\n> - When using template literals inside HTML script, okay.\n> - Use `innerHTML` with question strings. If topic contains characters like `<`? no.\n> - In `topicQuestionMap` use keys exactly as topic names. For level6 \"社会交往\" instead of \"土会交往\". \n> - For level3 \"集体活动\" instead of \"体活动\".\n> \n> Let's draft code.\n> \n> Need include `lang=\"zh-CN\"` in HTML.\n> \n> Full code below in final. But before final, let's maybe run mental check.\n> \n> The topic data:\n> ```\n> const LEVELS = [\n>  {level:\"1\", name:\"一级\", topics:[\"个人信息\",\"日常起居\",\"饮食\",\"交通\",\"兴趣爱好\"]},\n>  ...\n>  {level:\"6\", name:\"六级\", topics:[\"社会交往\",\"公司事务\",\"矛盾纷争\",\"社会新闻\",\"中外比较\"]},\n>  ...\n> ]\n> ```\n> \n> Question map:\n> ```\n> const topicQuestions = {\n> \"个人信息\": [\"你叫什么名字？\",\"你今年多大？\",\"你住在哪个城市？\"],\n> ...\n> }\n> ```\n> \n> Generate:\n> ```\n> function generateQuestions(level, topic){\n>  if(topicQuestions[topic]) return topicQuestions[topic].slice();\n>  const l = parseInt(level);\n>  const templates = {\n>   1:[`你喜欢${topic}吗？为什么？`,`你经常做什么和${topic}有关的事？`,`请简单介绍一下${topic}。`],\n>  ...\n>  };\n>  return (templates[level]||templates[1]).map(s=>s.replaceAll('${topic}', topic));\n> }\n> ```\n> But if using `replaceAll('${topic}', topic)`. In template string, `${topic}` inside backticks will attempt interpolation. Need escape: use string concatenation or avoid template literal. We can use array of functions:\n> ```\n> 1: [\n>   (t)=>`你喜欢${t}吗？为什么？`,\n>   (t)=>`你经常做什么和${t}有关的事？`,\n> ...\n> ]\n> ```\n> Then return templates[level].map(fn=>fn(topic)).\n> Good.\n> \n> Need `generateSystemPrompt(levelName, level, topic)`: use template literal.\n> \n> `generateOpening(levelName, topic)`: returns.\n> \n> `init`:\n> ```\n> els.levelSelect.innerHTML = LEVELS.map(l=>`<option value=\"${l.level}\">${l.name}</option>`).join('');\n> els.levelSelect.value = state.currentLevel;\n> renderTopics();\n> if (!state.config.openingMessage) state.config.openingMessage = generateOpening(...);\n> if (!state.config.systemPrompt) state.config.systemPrompt = generateSystemPrompt(...);\n> if (!state.config.questions.length) state.config.questions = generateQuestions(...);\n> startConversation(false?) \n> ```\n> But startConversation will generate. Let's manage:\n> - state has `config.questions` array.\n> - In `init`, load from localStorage may have questions. But we want auto per topic. Simpler: on every topic select, regenerate system prompt, opening, questions and overwrite state config. Settings can still show them. If user edits system prompt, and changes topic, will regenerate and lose edits. That's okay.\n> - In `startConversation`, set `state.config.questions = generateQuestions(...)`, `openingMessage = generateOpening(...)`, `systemPrompt = generateSystemPrompt(...)`. This may overwrite user edits. Accept.\n> \n> - `loadConfig` and `loadMemory` before populate. In `loadConfig`, if saved config exists, set. But then later `startConversation` may overwrite. So maybe don't load system prompt/opening questions? We'll still load API settings.\n> \n> State:\n> ```\n> const state = {\n>  currentLevel: '1',\n>  currentTopic: '个人信息',\n>  currentLevelName: '一级',\n>  history: [],\n>  config: {\n>    baseUrl:'https://api.openai.com/v1',\n>    apiKey:'',\n>    model:'gpt-4o-mini',\n>    temperature:0.7,\n>    systemPrompt:'',\n>    openingMessage:'',\n>    questions:[]\n>  },\n>  memory: { turns:0, userMessages:[], corrections:[], newWords:[] }\n> };\n> ```\n> \n> `loadConfig` only loads baseUrl, apiKey, model, temp maybe. Not prompt.\n> \n> `startConversation` sets prompts.\n> \n> Could include settings textarea for system prompt, opening, questions. After `startConversation`, call `syncSettingsFields()` if modal open? not necessary. We'll set when opening modal.\n> \n> `openSettings()`:\n> ```\n> els.settingBaseUrl.value = state.config.baseUrl;\n> ...\n> els.settingSystemPrompt.value = state.config.systemPrompt || generateSystemPrompt(...);\n> els.settingOpening.value = state.config.openingMessage || generateOpening(...);\n> els.settingQuestions.value = state.config.questions.join('\\n');\n> ```\n> \n> `saveSettings()`:\n> ```\n> state.config.baseUrl = ...;\n> state.config.apiKey = ...;\n> state.config.model = ...;\n> state.config.temperature = parseFloat(...);\n> state.config.systemPrompt = els.settingSystemPrompt.value.trim();\n> state.config.openingMessage = els.settingOpening.value.trim();\n> state.config.questions = els.settingQuestions.value.split('\\n').map(s=>s.trim()).filter(Boolean).slice(0,3);\n> localStorage.setItem('aiChineseConfig', JSON.stringify({ baseUrl, apiKey, model, temperature }));\n> toast('设置已保存');\n> close modal;\n> renderQuickQuestions(); maybe also update opening? We might not want to re-add opening. We'll only update questions.\n> ```\n> If questions length zero, fallback generated.\n> \n> Need `renderQuickQuestions` from state.config.questions.\n> \n> `saveMemory`:\n> ```\n> localStorage.setItem('aiChineseMemory', JSON.stringify(state.memory));\n> ```\n> On `loadMemory` set.\n> \n> But localStorage may have old memory with topic? we can load. On topic change memory resets.\n> \n> Memory drawer content:\n> ```\n> els.memoryContent.innerHTML = `\n>  <h3>🧠 当前记忆</h3>\n>  <p>等级：${state.currentLevelName} &nbsp; 话题：${state.currentTopic}</p>\n>  <p>对话轮数：${state.memory.turns}</p>\n>  <h4>🗣️ 学生发言</h4>\n>  <ul>${state.memory.userMessages.map(m=>`<li>${escapeHtml(m)}</li>`).join('')}</ul>\n>  <h4>📝 评估记录</h4>\n>  ...\n> `;\n> ```\n> Need `escapeHtml` for safety.\n> \n> Use `escapeHtml` for any `innerHTML` with user content.\n> \n> When `addMessage`, can use `innerText` so safe. For chips, innerHTML with escapeHtml.\n> \n> Implement `toast(msg)`.\n> \n> Voice:\n> ```\n> function startVoice() {\n>  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;\n>  if(!SR){ toast('当前浏览器不支持语音识别，请使用 Chrome'); return; }\n>  const rec = new SR();\n>  rec.lang='zh-CN';\n>  rec.interimResults=false;\n>  rec.onresult = e => { els.userInput.value = e.results[0][0].transcript; };\n>  rec.onerror = e => toast('语音识别错误: '+e.error);\n>  rec.start();\n> }\n> ```\n> \n> TTS:\n> ```\n> function speak(text) {\n>  if(!window.speechSynthesis) return;\n>  const u = new SpeechSynthesisUtterance(text);\n>  u.lang='zh-CN';\n>  u.rate=0.9;\n>  speechSynthesis.cancel();\n>  speechSynthesis.speak(u);\n> }\n> ```\n> \n> Need handle send button.\n> \n> Now, the final output may be large. Let's produce code block.\n> \n> But before final, let's maybe think if there is any requirement \"结合 AI 智能体的组成要素：大模型、规划（提示词，开场白，默认三个提问）、记忆、动作。\" The webpage should explicitly show these elements. We can include in settings modal with tabs or fields for planning; memory drawer shows memory; actions as buttons. Big model config in settings modal. Good.\n> \n> Potential additions:\n> - In header maybe \"大模型: 演示模式/已连接\" indicator.\n> - Top actions include \"🧠 记忆\" \"⚙️ 大模型\". Good.\n> \n> We can also include \"规划\" label in settings. The modal should include \"规划（提示词 / 开场白 / 默认提问）\" section.\n> \n> Let's refine settings modal content:\n> ```\n> <div class=\"modal-content\">\n>  <h2>⚙️ 大模型与规划</h2>\n>  <div class=\"form-row\"><label>API Base URL</label><input id=\"cfgBase\" /></div>\n>  ...\n>  <details open><summary>🧭 规划</summary>\n>   <label>系统提示词</label><textarea id=\"cfgSystem\" rows=6></textarea>\n>   <label>开场白</label><textarea id=\"cfgOpening\" rows=3></textarea>\n>   <label>默认三个提问（每行一个）</label><textarea id=\"cfgQuestions\" rows=3></textarea>\n>  </details>\n>  <div class=\"modal-actions\">...\n> ```\n> Maybe fields with emoji.\n> \n> Need maybe memory drawer with close button.\n> \n> Let's ensure CSS handles drawer; use fixed right.\n> \n> Let's produce.\n> \n> One concern: In HTML, if user content contains `</textarea>` etc, escapeHtml for settings. Fine.\n> \n> Another: in `renderQuickQuestions`, `state.config.questions` may contain HTML; escape.\n> \n> Now final code. Need ensure not use unsupported `replaceAll`? Fine in modern browsers. But we use functions so no.\n> \n> Let's write final code.\n> \n> Potential JS bug: `state.config.questions = generateQuestions(...)` returns array. In settings textarea, `.join('\\n')`. On `saveSettings`, `.split('\\n')...`. Good.\n> \n> `generateQuestions` for default if map missing uses functions object:\n> ```\n> const levelQuestionGenerators = {\n>  1: [\n>    t => `你喜欢${t}吗？为什么？`,\n> ...\n>  ],\n>  ...\n> };\n> ```\n> \n> Need `generateQuestions`:\n> ```\n> function generateQuestions(level, topic) {\n>  if (topicQuestions[topic]) return [...topicQuestions[topic]];\n>  const gens = levelQuestionGenerators[level] || levelQuestionGenerators['1'];\n>  return gens.map(fn => fn(topic));\n> }\n> ```\n> \n> Now `init`:\n> ```\n> function init(){\n>   els = {...};\n>   loadConfig();\n>   loadMemory();\n>   populateLevelSelect();\n>   renderTopics();\n>   bindEvents();\n>   updateHeader();\n>   startConversation();\n> }\n> ```\n> But `renderTopics` maybe calls `setActiveTopic`? We can set currentTopic maybe from memory or first. Implementation:\n> ```\n> function renderTopics() {\n>  const level = LEVELS.find(l=>l.level===state.currentLevel);\n>  const topics = level.topics;\n>  if (!topics.includes(state.currentTopic)) {\n>    state.currentTopic = topics[0];\n>  }\n>  els.topicList.innerHTML = topics.map(topic=>`<button class=\"topic-item ${topic===state.currentTopic?'active':''}\" data-topic=\"${topic}\">${topic}</button>`).join('');\n> }\n> ```\n> Need `state.currentLevelName = level.name`.\n> \n> But if load memory with currentTopic not in list? Update.\n> \n> `bindEvents`: topicList click delegation:\n> ```\n> els.topicList.addEventListener('click', e=>{\n>  const btn = e.target.closest('.topic-item');\n>  if(!btn) return;\n>  selectTopic(btn.dataset.topic);\n> });\n> ```\n> \n> `selectLevel`: set currentLevel, state.currentLevelName, renderTopics, selectTopic(first topic).\n> But if `renderTopics` sets currentTopic from maybe previous level; then `selectTopic` called on first. Simpler:\n> ```\n> function onLevelChange(e){\n>  state.currentLevel = e.target.value;\n>  state.currentLevelName = LEVELS.find(l=>l.level===state.currentLevel).name;\n>  const topics = getTopics();\n>  state.currentTopic = topics[0];\n>  renderTopics();\n>  startConversation();\n> }\n> ```\n> Need `renderTopics` use currentTopic.\n> \n> `selectTopic(topic)`:\n> ```\n> state.currentTopic = topic;\n> updateHeader();\n> startConversation();\n> ```\n> \n> `updateHeader`:\n> ```\n> els.activeTopic.textContent = state.currentTopic;\n> els.activeLevel.textContent = state.currentLevelName;\n> els.headerTopic.textContent = `${state.currentLevelName}·${state.currentTopic}`;\n> ```\n> \n> `startConversation`:\n> ```\n> state.history = [];\n> state.memory = { turns:0, userMessages:[], corrections:[], newWords:[] };\n> state.config.systemPrompt = generateSystemPrompt(...);\n> state.config.openingMessage = generateOpening(...);\n> state.config.questions = generateQuestions(...);\n> els.messages.innerHTML='';\n> addMessage('assistant', state.config.openingMessage);\n> renderQuickQuestions();\n> saveMemory();\n> ```\n> \n> Need caution: `addMessage` uses `state.history.push`, which we just cleared. Good.\n> \n> `addMessage`:\n> ```\n> function addMessage(role, content) {\n>   state.history.push({role, content});\n>   const div = document.createElement('div');\n>   div.className = `message ${role}`;\n>   const bubble = document.createElement('div');\n>   bubble.className = 'bubble';\n>   bubble.innerText = content;\n>   const actions = ...\n>   div.appendChild(bubble);\n>   if(role === 'assistant') {\n>     const acts = document.createElement('div');\n>     acts.className = 'msg-actions';\n>     acts.innerHTML = '<button class=\"mini-btn msg-speak\">🔊</button>';\n>     div.appendChild(acts);\n>   }\n>   els.messages.appendChild(div);\n>   scrollToBottom();\n> }\n> ```\n> Maybe add speech button per assistant bubble. Need event delegation. Use `data-action=\"speak\"`.\n> \n> In `bindEvents`, messages click:\n> ```\n> els.messages.addEventListener('click', e=>{\n>  const btn = e.target.closest('.msg-speak');\n>  if(btn){ const bubble = btn.closest('.message').querySelector('.bubble'); speak(bubble.innerText); }\n> });\n> ```\n> \n> `renderQuickQuestions`:\n> ```\n> els.quickQuestions.innerHTML = `<div class=\"quick-title\">✨ 默认三个提问</div>` + state.config.questions.map(q=>`<button class=\"chip\">${escapeHtml(q)}</button>`).join('');\n> ```\n> Clicks delegated.\n> \n> Need `clearChat` maybe button. In header maybe \"新对话\". We can add `btnClear` in top-actions. On click `if(confirm('开始新对话并清空当前记忆？')) startConversation();`.\n> \n> Buttons bind:\n> - btnSend click sendMessage\n> - userInput keydown Enter send (shift+enter newline)\n> - btnVoice click startVoice\n> - btnSpeak click speak last AI\n> - btnEval click evaluate\n> - btnMemory click toggle memory drawer\n> - btnSettings click open settings\n> - btnClear click clear conversation\n> - close settings, save settings\n> - close memory\n> \n> Need memory drawer `btnCloseMemory`.\n> \n> Implement `getLastAssistantContent`:\n> ```\n> function getLastAssistantContent() {\n>  const arr = state.history.filter(m=>m.role==='assistant');\n>  return arr.length ? arr[arr.length-1].content : '';\n> }\n> ```\n> \n> Evaluate function:\n> ```\n> async function evaluate() {\n>  const userMsgs = state.history.filter(m=>m.role==='user');\n>  if(!userMsgs.length) { toast('请先输入至少一句话'); return; }\n>  toast('评估中...');\n>  const lastUser = userMsgs[userMsgs.length-1].content;\n>  let feedback;\n>  try {\n>    if(state.config.apiKey){\n>      const sys = `你是国际中文教育评估专家。根据《国际中文教育中文水平等级标准》${state.currentLevelName}，请对学生的句子进行简短反馈：先肯定优点，再指出1-2个可改进的语法或词汇问题（若无问题则说表达自然），最后给一个更地道的说法。用中文，不超过120字。`;\n>      feedback = await callOpenAI([{role:'system',content:sys},{role:'user',content:lastUser}]);\n>    } else {\n>      feedback = `【模拟评估】你的表达基本清楚。建议多使用完整句子，并注意“了”“的”等虚词。继续加油！`;\n>    }\n>    addMessage('assistant', `📝 评估反馈：${feedback}`);\n>    state.memory.corrections.push({input:lastUser, feedback});\n>    saveMemory();\n>  } catch(e) { toast('评估失败：'+e.message); }\n> }\n> ```\n> \n> Call `callOpenAI` if API key. Good.\n> \n> Need `callOpenAI`:\n> ```\n> async function callOpenAI(messages, temp=state.config.temperature) {\n>  const base = state.config.baseUrl.replace(/\\/+$/,'');\n>  const url = `${base}/chat/completions`;\n>  const response = await fetch(url, {\n>    method:'POST',\n>    headers:{'Content-Type':'application/json','Authorization':'Bearer '+state.config.apiKey},\n>    body: JSON.stringify({model: state.config.model, messages, temperature: temp})\n>  });\n>  if(!response.ok) { const text=await response.text(); throw new Error(`HTTP ${response.status}: ${text.slice(0,200)}`); }\n>  const data = await response.json();\n>  if(!data.choices || !data.choices[0] || !data.choices[0].message) throw new Error('响应格式错误');\n>  return data.choices[0].message.content.trim();\n> }\n> ```\n> \n> `getAIResponse(userText)`:\n> ```\n> async function getAIResponse(userText){\n>   if(!state.config.apiKey) return demoResponse(userText);\n>   try{\n>     const messages = [\n>       {role:'system', content: state.config.systemPrompt},\n>       ...state.history.slice(-20) // note state.history already includes last user and prior\n>     ];\n>     const reply = await callOpenAI(messages);\n>     return reply;\n>   } catch(e) {\n>     toast('模型调用失败，已使用演示回复');\n>     return demoResponse(userText);\n>   }\n> }\n> ```\n> Need careful: `state.history` at call includes new user message? In sendMessage we push before call. Good.\n> \n> `demoResponse`:\n> ```\n> function demoResponse(){\n>  const qs = state.config.questions.length ? state.config.questions : generateQuestions(...);\n>  const replies = [\n>    `我明白你的意思了。关于“${state.currentTopic}”，我还想问：${qs[0]}`,\n>    `你说得很好！那么，${qs[1]}`,\n>    `有意思！${qs[2]}`,\n>    `谢谢你的分享。能不能再说得详细一点？`\n>  ];\n>  return replies[Math.floor(Math.random()*replies.length)];\n> }\n> ```\n> Maybe avoid repeating same question if just asked. Fine.\n> \n> Need `typingIndicator`:\n> ```\n> function showTyping(){\n>  els.typing = document.createElement('div');\n>  els.typing.className='message assistant';\n>  els.typing.innerHTML='<div class=\"bubble typing\"><span></span><span></span><span></span></div>';\n>  els.messages.appendChild(els.typing);\n>  scrollToBottom();\n> }\n> function removeTyping(){ if(els.typing){ els.typing.remove(); els.typing=null; } }\n> ```\n> Need CSS for .typing spans.\n> \n> Now settings modal open:\n> ```\n> function openSettings(){\n>  els.cfgBase.value = state.config.baseUrl;\n>  ...\n>  els.cfgQuestions.value = state.config.questions.join('\\n');\n>  els.settingsModal.classList.add('show');\n> }\n> ```\n> Close: remove show.\n> \n> LocalStorage config:\n> ```\n> function loadConfig(){\n>  try{\n>   const saved = localStorage.getItem('aiChineseConfig');\n>   if(saved){\n>     const c = JSON.parse(saved);\n>     Object.assign(state.config, c);\n>   }\n>  }catch(e){}\n> }\n> function saveConfig(){ localStorage.setItem('aiChineseConfig', JSON.stringify({baseUrl:...,apiKey:...,model:...,temperature:...})); }\n> ```\n> Do not save prompts? We can save too maybe. But prompts generated on topic. Save system prompt? Could include; but if saved and then topic changes, overwritten. But okay. Save all config fields.\n> \n> Memory:\n> ```\n> function loadMemory(){\n>  try{\n>   const saved = localStorage.getItem('aiChineseMemory');\n>   if(saved){\n>     const m = JSON.parse(saved);\n>     if(m) state.memory = m;\n>   }\n>  }catch(e){}\n> }\n> function saveMemory(){ localStorage.setItem('aiChineseMemory', JSON.stringify(state.memory)); }\n> ```\n> But `startConversation` resets memory then saves.\n> \n> Potential issue: On initial load, state.memory.turns maybe from previous conversation; then startConversation resets. Good.\n> \n> Need maybe update memory drawer content when opened.\n> \n> Now final CSS/HTML. Let's craft carefully. The page is responsive:\n> - topbar fixed? maybe sticky.\n> - container flex.\n> - sidebar width 260px.\n> - chat area flex 1.\n> - memory drawer fixed overlay right, width 340px.\n> - modal.\n> \n> Let's write final code in final.\n> \n> Need ensure no markdown errors. We'll wrap in ```html.\n> \n> Let's produce.\n</details>\n以下是一个可直接运行的 HTML 网页项目，面向国际中文教育口语实践场景，包含大模型配置、规划提示词、开场白、默认三个提问、记忆面板与语音/朗读/评估等动作交互。\n```html\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n<title>AI 中文实践智能体</title>\n<style>\n  :root{\n    --primary:#2b3a55;\n    --primary-dark:#1d2b3f;\n    --accent:#3b82f6;\n    --accent-light:#eaf2ff;\n    --bg:#f4f6fb;\n    --card:#ffffff;\n    --text:#1f2937;\n    --muted:#6b7280;\n    --border:#e5e7eb;\n    --user-bubble:#2563eb;\n    --user-text:#ffffff;\n    --assistant-bubble:#ffffff;\n    --radius:16px;\n    --shadow:0 10px 25px rgba(0,0,0,0.06);\n  }\n\n  *{box-sizing:border-box;margin:0;padding:0;font-family:\"Noto Sans SC\",\"PingFang SC\",\"Microsoft YaHei\",system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;}\n\n  body{\n    background:var(--bg);\n    color:var(--text);\n    height:100vh;\n    display:flex;\n    flex-direction:column;\n    overflow:hidden;\n  }\n\n  /* 顶部导航 */\n  .topbar{\n    background:linear-gradient(135deg,var(--primary-dark),var(--primary));\n    color:#fff;\n    padding:14px 22px;\n    display:flex;\n    align-items:center;\n    justify-content:space-between;\n    flex-shrink:0;\n    box-shadow:0 2px 10px rgba(0,0,0,0.12);\n    z-index:10;\n  }\n  .brand{\n    display:flex;\n    align-items:center;\n    gap:12px;\n  }\n  .brand .logo{\n    font-size:26px;\n    line-height:1;\n  }\n  .brand h1{\n    font-size:18px;\n    font-weight:650;\n    letter-spacing:.5px;\n  }\n  .brand .subtitle{\n    font-size:12px;\n    opacity:.75;\n    margin-top:2px;\n  }\n  .top-actions{\n    display:flex;\n    align-items:center;\n    gap:10px;\n  }\n  .top-actions button,.top-actions .badge{\n    border:none;\n    background:rgba(255,255,255,.12);\n    color:#fff;\n    padding:8px 13px;\n    border-radius:10px;\n    cursor:pointer;\n    font-size:13px;\n    transition:all .2s;\n  }\n  .top-actions button:hover{\n    background:rgba(255,255,255,.22);\n  }\n  .header-topic{\n    font-size:13px;\n    background:rgba(255,255,255,.15);\n    padding:6px 12px;\n    border-radius:999px;\n  }\n\n  /* 主体布局 */\n  .layout{\n    flex:1;\n    display:flex;\n    min-height:0;\n  }\n  .sidebar{\n    width:260px;\n    background:var(--card);\n    border-right:1px solid var(--border);\n    padding:18px 14px;\n    overflow-y:auto;\n    flex-shrink:0;\n  }\n  .sidebar h2{\n    font-size:15px;\n    margin-bottom:12px;\n    display:flex;\n    align-items:center;\n    gap:6px;\n  }\n  .sidebar select{\n    width:100%;\n    padding:9px 11px;\n    border:1px solid var(--border);\n    border-radius:10px;\n    font-size:14px;\n    background:#fff;\n    margin-bottom:14px;\n    outline:none;\n  }\n  .topic-list{\n    display:grid;\n    grid-template-columns:1fr 1fr;\n    gap:8px;\n  }\n  .topic-item{\n    border:1px solid var(--border);\n    background:#fff;\n    padding:9px 8px;\n    border-radius:10px;\n    cursor:pointer;\n    font-size:13px;\n    text-align:center;\n    transition:all .18s;\n  }\n  .topic-item:hover{\n    border-color:var(--accent);\n    color:var(--accent);\n  }\n  .topic-item.active{\n    background:var(--accent);\n    border-color:var(--accent);\n    color:#fff;\n    font-weight:600;\n  }\n\n  .chat-area{\n    flex:1;\n    display:flex;\n    flex-direction:column;\n    min-width:0;\n    background:var(--bg);\n  }\n  .chat-header{\n    background:#fff;\n    padding:14px 22px;\n    border-bottom:1px solid var(--border);\n    flex-shrink:0;\n  }\n  .chat-header .title-row{\n    display:flex;\n    align-items:center;\n    gap:10px;\n  }\n  .chat-header .title-row strong{\n    font-size:17px;\n  }\n  .chat-header .badge-level{\n    background:var(--accent-light);\n    color:var(--accent);\n    font-size:12px;\n    padding:3px 10px;\n    border-radius:999px;\n    font-weight:600;\n  }\n  .scenario{\n    color:var(--muted);\n    font-size:13px;\n    margin-top:5px;\n  }\n\n  .messages{\n    flex:1;\n    overflow-y:auto;\n    padding:22px 24px 12px;\n    display:flex;\n    flex-direction:column;\n    gap:14px;\n  }\n  .message{\n    display:flex;\n    flex-direction:column;\n    max-width:82%;\n    animation:fadeIn .25s ease;\n  }\n  .message.user{\n    align-self:flex-end;\n    align-items:flex-end;\n  }\n  .message.assistant{\n    align-self:flex-start;\n    align-items:flex-start;\n  }\n  .bubble{\n    padding:12px 15px;\n    border-radius:var(--radius);\n    font-size:15px;\n    line-height:1.65;\n    white-space:pre-wrap;\n    word-break:break-word;\n    box-shadow:var(--shadow);\n  }\n  .message.user .bubble{\n    background:var(--user-bubble);\n    color:#fff;\n    border-bottom-right-radius:4px;\n  }\n  .message.assistant .bubble{\n    background:var(--assistant-bubble);\n    border:1px solid var(--border);\n    border-bottom-left-radius:4px;\n  }\n  .msg-actions{\n    margin-top:5px;\n    display:flex;\n    gap:6px;\n  }\n  .mini-btn{\n    border:1px solid var(--border);\n    background:#fff;\n    border-radius:999px;\n    padding:3px 9px;\n    cursor:pointer;\n    font-size:12px;\n    transition:all .15s;\n  }\n  .mini-btn:hover{\n    background:#f3f4f6;\n  }\n\n  .typing{\n    display:flex;\n    gap:4px;\n    align-items:center;\n    padding:5px 0;\n  }\n  .typing span{\n    width:7px;\n    height:7px;\n    background:#9ca3af;\n    border-radius:50%;\n    display:inline-block;\n    animation:blink 1.2s infinite both;\n  }\n  .typing span:nth-child(2){animation-delay:.15s;}\n  .typing span:nth-child(3){animation-delay:.3s;}\n  @keyframes blink{0%,80%,100%{opacity:.25;}40%{opacity:1;}}\n  @keyframes fadeIn{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:translateY(0);}}\n\n  .quick-questions{\n    padding:8px 24px 12px;\n    display:flex;\n    flex-wrap:wrap;\n    gap:8px;\n    align-items:center;\n    flex-shrink:0;\n  }\n  .quick-title{\n    font-size:13px;\n    color:var(--muted);\n    margin-right:4px;\n  }\n  .chip{\n    background:#fff;\n    border:1px solid var(--border);\n    border-radius:999px;\n    padding:7px 13px;\n    font-size:13px;\n    cursor:pointer;\n    transition:all .18s;\n    color:var(--text);\n  }\n  .chip:hover{\n    border-color:var(--accent);\n    color:var(--accent);\n    background:var(--accent-light);\n  }\n\n  .composer{\n    background:#fff;\n    border-top:1px solid var(--border);\n    padding:14px 22px;\n    display:flex;\n    gap:10px;\n    align-items:flex-end;\n    flex-shrink:0;\n  }\n  .composer textarea{\n    flex:1;\n    resize:none;\n    border:1px solid var(--border);\n    border-radius:12px;\n    padding:11px 13px;\n    font-size:15px;\n    min-height:46px;\n    max-height:130px;\n    outline:none;\n    line-height:1.5;\n  }\n  .composer textarea:focus{\n    border-color:var(--accent);\n    box-shadow:0 0 0 2px rgba(59,130,246,.10);\n  }\n  .composer button{\n    border:none;\n    background:var(--accent);\n    color:#fff;\n    width:46px;\n    height:46px;\n    border-radius:12px;\n    cursor:pointer;\n    font-size:18px;\n    transition:all .2s;\n    flex-shrink:0;\n  }\n  .composer button:hover{\n    background:#1d4ed8;\n  }\n  .composer button.secondary{\n    background:#f3f4f6;\n    color:var(--text);\n    border:1px solid var(--border);\n    font-size:16px;\n  }\n  .composer button.secondary:hover{\n    background:#e5e7eb;\n  }\n  .composer button:disabled{\n    opacity:.55;\n    cursor:not-allowed;\n  }\n\n  /* 抽屉 */\n  .drawer,.modal{\n    position:fixed;\n    top:0;\n    right:0;\n    height:100%;\n    width:380px;\n    max-width:90vw;\n    background:#fff;\n    box-shadow:-8px 0 30px rgba(0,0,0,0.15);\n    z-index:40;\n    transform:translateX(100%);\n    transition:transform .3s ease;\n    display:flex;\n    flex-direction:column;\n  }\n  .drawer.show,.modal.show{\n    transform:translateX(0);\n  }\n  .drawer-header,.modal-header{\n    padding:16px 20px;\n    border-bottom:1px solid var(--border);\n    display:flex;\n    align-items:center;\n    justify-content:space-between;\n  }\n  .drawer-header h3,.modal-header h3{\n    font-size:16px;\n  }\n  .drawer-body,.modal-body{\n    padding:18px 20px;\n    overflow-y:auto;\n    flex:1;\n  }\n  .close-btn{\n    background:none;\n    border:none;\n    font-size:22px;\n    cursor:pointer;\n    color:var(--muted);\n  }\n  .memory-item{\n    margin-bottom:16px;\n  }\n  .memory-item h4{\n    font-size:14px;\n    margin-bottom:8px;\n    color:var(--primary);\n  }\n  .memory-list{\n    list-style:none;\n    display:flex;\n    flex-direction:column;\n    gap:7px;\n  }\n  .memory-list li{\n    background:#f9fafb;\n    border:1px solid var(--border);\n    padding:8px 10px;\n    border-radius:9px;\n    font-size:13px;\n  }\n  .memory-stats{\n    display:flex;\n    gap:12px;\n    flex-wrap:wrap;\n    margin-bottom:14px;\n  }\n  .memory-stats span{\n    background:var(--accent-light);\n    color:var(--accent);\n    padding:6px 12px;\n    border-radius:999px;\n    font-size:12px;\n    font-weight:600;\n  }\n\n  /* 设置弹窗 */\n  .modal{\n    width:520px;\n  }\n  .form-group{\n    margin-bottom:15px;\n  }\n  .form-group label{\n    display:block;\n    font-size:13px;\n    font-weight:600;\n    margin-bottom:6px;\n    color:#374151;\n  }\n  .form-group input,.form-group textarea{\n    width:100%;\n    border:1px solid var(--border);\n    border-radius:10px;\n    padding:9px 11px;\n    font-size:14px;\n    outline:none;\n    background:#fff;\n  }\n  .form-group textarea{\n    resize:vertical;\n    min-height:70px;\n    line-height:1.5;\n  }\n  .form-group input:focus,.form-group textarea:focus{\n    border-color:var(--accent);\n  }\n  .form-row{\n    display:grid;\n    grid-template-columns:1fr 1fr;\n    gap:12px;\n  }\n  .modal-footer{\n    padding:14px 20px;\n    border-top:1px solid var(--border);\n    display:flex;\n    justify-content:flex-end;\n    gap:10px;\n  }\n  .btn-primary,.btn-outline{\n    padding:9px 16px;\n    border-radius:10px;\n    cursor:pointer;\n    font-size:14px;\n    border:1px solid transparent;\n    transition:all .2s;\n  }\n  .btn-primary{\n    background:var(--accent);\n    color:#fff;\n  }\n  .btn-primary:hover{\n    background:#1d4ed8;\n  }\n  .btn-outline{\n    background:#fff;\n    border-color:var(--border);\n    color:var(--text);\n  }\n  .btn-outline:hover{\n    background:#f9fafb;\n  }\n\n  .toast{\n    position:fixed;\n    bottom:28px;\n    left:50%;\n    transform:translateX(-50%) translateY(80px);\n    background:#111827;\n    color:#fff;\n    padding:10px 18px;\n    border-radius:999px;\n    font-size:14px;\n    z-index:60;\n    opacity:0;\n    transition:all .3s;\n    pointer-events:none;\n  }\n  .toast.show{\n    opacity:1;\n    transform:translateX(-50%) translateY(0);\n  }\n\n  .overlay{\n    position:fixed;\n    inset:0;\n    background:rgba(0,0,0,.35);\n    z-index:35;\n    opacity:0;\n    pointer-events:none;\n    transition:opacity .3s;\n  }\n  .overlay.show{\n    opacity:1;\n    pointer-events:auto;\n  }\n\n  @media (max-width:800px){\n    .layout{flex-direction:column;}\n    .sidebar{width:100%;max-height:180px;border-right:none;border-bottom:1px solid var(--border);}\n    .topic-list{grid-template-columns:repeat(3,1fr);}\n    .message{max-width:95%;}\n  }\n</style>\n</head>\n<body>\n<div class=\"topbar\">\n  <div class=\"brand\">\n    <span class=\"logo\">🗣️</span>\n    <div>\n      <h1>AI 中文实践智能体</h1>\n      <div class=\"subtitle\">基于《国际中文教育中文水平等级标准》</div>\n    </div>\n  </div>\n  <div class=\"top-actions\">\n    <span class=\"header-topic\" id=\"headerTopic\">一级·个人信息</span>\n    <button id=\"btnMemory\" title=\"查看记忆\">🧠 记忆</button>\n    <button id=\"btnClear\" title=\"新对话\">🔄 新对话</button>\n    <button id=\"btnSettings\" title=\"大模型与规划\">⚙️ 大模型</button>\n  </div>\n</div>\n\n<div class=\"layout\">\n  <aside class=\"sidebar\">\n    <h2>📚 等级与话题</h2>\n    <select id=\"levelSelect\"></select>\n    <div class=\"topic-list\" id=\"topicList\"></div>\n  </aside>\n\n  <main class=\"chat-area\">\n    <div class=\"chat-header\">\n      <div class=\"title-row\">\n        <strong id=\"activeTopic\">个人信息</strong>\n        <span class=\"badge-level\" id=\"activeLevel\">一级</span>\n      </div>\n      <p class=\"scenario\" id=\"scenario\">围绕当前等级与话题进行中文对话实践，智能体将根据你的水平引导提问。</p>\n    </div>\n\n    <div class=\"messages\" id=\"messages\"></div>\n\n    <div class=\"quick-questions\" id=\"quickQuestions\"></div>\n\n    <div class=\"composer\">\n      <button class=\"secondary\" id=\"btnVoice\" title=\"语音输入\">🎤</button>\n      <textarea id=\"userInput\" placeholder=\"输入中文，和智能体聊天...\" rows=\"1\"></textarea>\n      <button class=\"secondary\" id=\"btnSpeak\" title=\"朗读最后回复\">🔊</button>\n      <button class=\"secondary\" id=\"btnEvaluate\" title=\"评估反馈\">📝</button>\n      <button id=\"btnSend\" title=\"发送\">➤</button>\n    </div>\n  </main>\n</div>\n\n<!-- 遮罩 -->\n<div class=\"overlay\" id=\"overlay\"></div>\n\n<!-- 记忆抽屉 -->\n<div class=\"drawer\" id=\"memoryDrawer\">\n  <div class=\"drawer-header\">\n    <h3>🧠 智能体记忆</h3>\n    <button class=\"close-btn\" id=\"btnCloseMemory\">×</button>\n  </div>\n  <div class=\"drawer-body\" id=\"memoryContent\"></div>\n</div>\n\n<!-- 设置弹窗 -->\n<div class=\"modal\" id=\"settingsModal\">\n  <div class=\"modal-header\">\n    <h3>⚙️ 大模型与规划</h3>\n    <button class=\"close-btn\" id=\"btnCloseSettings\">×</button>\n  </div>\n  <div class=\"modal-body\">\n    <div class=\"form-group\">\n      <label>🤖 API Base URL</label>\n      <input id=\"cfgBaseUrl\" placeholder=\"例如 https://api.openai.com/v1\" />\n    </div>\n    <div class=\"form-row\">\n      <div class=\"form-group\">\n        <label>🔑 API Key</label>\n        <input id=\"cfgApiKey\" type=\"password\" placeholder=\"sk-...\" />\n      </div>\n      <div class=\"form-group\">\n        <label>🧩 模型名称</label>\n        <input id=\"cfgModel\" placeholder=\"gpt-4o-mini\" />\n      </div>\n    </div>\n    <div class=\"form-group\">\n      <label>🌡️ Temperature（0-1.5）</label>\n      <input id=\"cfgTemperature\" type=\"number\" step=\"0.1\" min=\"0\" max=\"1.5\" />\n    </div>\n\n    <div class=\"form-group\">\n      <label>🧭 系统提示词（规划）</label>\n      <textarea id=\"cfgSystem\" rows=\"6\"></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label>👋 开场白</label>\n      <textarea id=\"cfgOpening\" rows=\"3\"></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label>❓ 默认三个提问（每行一个）</label>\n      <textarea id=\"cfgQuestions\" rows=\"3\"></textarea>\n    </div>\n    <p style=\"font-size:12px;color:#6b7280;line-height:1.5;\">💡 提示：如不填写 API Key，将自动使用内置演示模式，便于体验交互流程。</p>\n  </div>\n  <div class=\"modal-footer\">\n    <button class=\"btn-outline\" id=\"btnResetPrompt\">恢复默认规划</button>\n    <button class=\"btn-outline\" id=\"btnCloseSettings2\">取消</button>\n    <button class=\"btn-primary\" id=\"btnSaveSettings\">保存设置</button>\n  </div>\n</div>\n\n<div class=\"toast\" id=\"toast\"></div>\n\n<script>\n  /* ==================== 话题数据 ==================== */\n  const LEVELS = [\n    {\"level\":\"1\",\"name\":\"一级\",\"topics\":[\"个人信息\",\"日常起居\",\"饮食\",\"交通\",\"兴趣爱好\"]},\n    {\"level\":\"2\",\"name\":\"二级\",\"topics\":[\"基本社交\",\"家庭生活\",\"学习安排\",\"购物\",\"用餐\",\"个人感受\"]},\n    {\"level\":\"3\",\"name\":\"三级\",\"topics\":[\"出行经历\",\"课程情况\",\"集体活动\",\"节日习俗\",\"教育\",\"职业\"]},\n    {\"level\":\"4\",\"name\":\"四级\",\"topics\":[\"社区生活\",\"健康状况\",\"校园生活\",\"日常办公\",\"动物\",\"植物\"]},\n    {\"level\":\"5\",\"name\":\"五级\",\"topics\":[\"人际关系\",\"生活方式\",\"学习方法\",\"自然环境\",\"社会现象\"]},\n    {\"level\":\"6\",\"name\":\"六级\",\"topics\":[\"社会交往\",\"公司事务\",\"矛盾纷争\",\"社会新闻\",\"中外比较\"]},\n    {\"level\":\"7\",\"name\":\"七级\",\"topics\":[\"社交礼仪\",\"科学技术\",\"文艺\",\"体育\",\"心理情感\",\"专业课程\"]},\n    {\"level\":\"8\",\"name\":\"八级\",\"topics\":[\"语言文字\",\"政治经济\",\"法律事务\",\"哲学\",\"历史\"]},\n    {\"level\":\"9\",\"name\":\"九级\",\"topics\":[\"学术研究\",\"政策法规\",\"商业贸易\",\"国际事务\"]}\n  ];\n\n  const topicQuestions = {\n    \"个人信息\":[\"你叫什么名字？\",\"你今年多大？\",\"你住在哪个城市？\"],\n    \"日常起居\":[\"你每天几点起床？\",\"你早上一般做什么？\",\"你晚上几点睡觉？\"],\n    \"饮食\":[\"你喜欢吃什么菜？\",\"你早餐一般吃什么？\",\"你喝咖啡还是茶？\"],\n    \"交通\":[\"你每天怎么去学校或上班？\",\"你喜欢坐地铁还是公交车？\",\"你骑过自行车吗？\"],\n    \"兴趣爱好\":[\"你有什么爱好？\",\"你周末喜欢做什么？\",\"你喜欢运动吗？\"],\n    \"基本社交\":[\"你见到朋友怎么打招呼？\",\"你会邀请朋友来家里吗？\",\"你常用什么社交软件？\"],\n    \"家庭生活\":[\"你家有几口人？\",\"你和家人一起做什么？\",\"你常和父母聊天吗？\"],\n    \"学习安排\":[\"你每天学习多长时间中文？\",\"你用什么方法学中文？\",\"你周末有学习计划吗？\"],\n    \"购物\":[\"你常去哪儿买东西？\",\"你网上购物多吗？\",\"你买东西会讲价吗？\"],\n    \"用餐\":[\"你常去餐厅吃饭吗？\",\"你喜欢中餐还是西餐？\",\"你会用筷子吗？\"],\n    \"个人感受\":[\"你今天感觉怎么样？\",\"什么事情让你开心？\",\"你最近有压力吗？\"],\n    \"出行经历\":[\"你去过哪些地方旅行？\",\"你最难忘的一次出行是什么？\",\"旅行时你最喜欢做什么？\"],\n    \"课程情况\":[\"你现在在上什么课？\",\"你最喜欢哪门课？为什么？\",\"你觉得哪门课最难？\"],\n    \"集体活动\":[\"你参加过什么集体活动？\",\"你喜欢参加集体活动吗？\",\"你们班常组织活动吗？\"],\n    \"节日习俗\":[\"你国家最重要的节日是什么？\",\"春节和你们国家的节日有什么不同？\",\"你庆祝节日时吃什么？\"],\n    \"教育\":[\"你觉得什么样的教育是好教育？\",\"你国家的教育制度怎么样？\",\"你最喜欢的老师是谁？\"],\n    \"职业\":[\"你以后想做什么工作？\",\"你父母的职业是什么？\",\"你觉得什么职业最有意思？\"],\n    \"社区生活\":[\"你住的小区怎么样？\",\"你和邻居关系好吗？\",\"社区里有什么设施？\"],\n    \"健康状况\":[\"你最近身体怎么样？\",\"你每天睡几个小时？\",\"你常锻炼身体吗？\"],\n    \"校园生活\":[\"你的学校大吗？\",\"你课后常参加什么活动？\",\"你最喜欢学校的哪个地方？\"],\n    \"日常办公\":[\"你工作时常用电脑吗？\",\"你习惯在家办公还是在办公室？\",\"你怎么安排每天的工作？\"],\n    \"动物\":[\"你喜欢什么动物？\",\"你养过宠物吗？\",\"你觉得动物园好不好？\"],\n    \"植物\":[\"你喜欢种花草吗？\",\"你家里有植物吗？\",\"你去过植物园吗？\"],\n    \"人际关系\":[\"你觉得朋友之间最重要的是什么？\",\"你怎么认识新朋友？\",\"你和朋友吵过架吗？\"],\n    \"生活方式\":[\"你的生活方式健康吗？\",\"你理想的生活是什么样的？\",\"你和父母的作息一样吗？\"],\n    \"学习方法\":[\"你有什么好的学习方法？\",\"学中文时你怎么记生词？\",\"你更喜欢自学还是上课？\"],\n    \"自然环境\":[\"你觉得你城市的环境怎么样？\",\"我们应该怎么保护环境？\",\"你喜欢去大自然吗？\"],\n    \"社会现象\":[\"你最近关注什么社会现象？\",\"你对社交媒体怎么看？\",\"你身边有志愿者活动吗？\"],\n    \"社会交往\":[\"在社交场合遇到矛盾你会怎么办？\",\"你怎样维护和同事的关系？\",\"你觉得网络社交和现实社交一样吗？\"],\n    \"公司事务\":[\"你了解公司一般怎么开会吗？\",\"如果你和同事意见不同会怎么办？\",\"你觉得领导应该怎么做？\"],\n    \"矛盾纷争\":[\"如果朋友误会你，你会怎么做？\",\"你怎么处理人与人之间的矛盾？\",\"你觉得沟通能解决所有矛盾吗？\"],\n    \"社会新闻\":[\"你最近看了什么新闻？\",\"你常看哪方面的新闻？\",\"你觉得新闻真实重要吗？\"],\n    \"中外比较\":[\"你觉得中国和你的国家有什么不同？\",\"在饮食方面你的国家和中国有什么差异？\",\"你更适应哪种生活方式？\"],\n    \"社交礼仪\":[\"在正式场合应该注意哪些礼仪？\",\"你们国家的社交礼仪有什么特点？\",\"如果你迟到了会怎么解释？\"],\n    \"科学技术\":[\"你觉得人工智能会改变教育吗？\",\"你常使用哪些科技产品？\",\"科学技术给你带来什么方便？\"],\n    \"文艺\":[\"你喜欢什么类型的文艺作品？\",\"你看过中国的电影吗？\",\"你更喜欢小说还是电影？\"],\n    \"体育\":[\"你喜欢看什么体育比赛？\",\"你平时参加什么运动？\",\"体育运动对你有什么帮助？\"],\n    \"心理情感\":[\"你压力大的时候会怎么做？\",\"你怎么保持积极情绪？\",\"你会和朋友分享心情吗？\"],\n    \"专业课程\":[\"你的专业是什么？\",\"你为什么选择这个专业？\",\"你最喜欢的专业课是什么？\"],\n    \"语言文字\":[\"你学中文遇到的最大困难是什么？\",\"你觉得汉字难吗？\",\"语言和文化有什么关系？\"],\n    \"政治经济\":[\"你关注经济新闻吗？\",\"你觉得经济发展对普通人有什么影响？\",\"你了解中国的经济政策吗？\"],\n    \"法律事务\":[\"你了解哪些法律常识？\",\"在你的国家法律如何保护消费者？\",\"你觉得法律和道德有什么不同？\"],\n    \"哲学\":[\"你思考过人生的意义吗？\",\"你读过哲学方面的书吗？\",\"你觉得幸福是什么？\"],\n    \"历史\":[\"你喜欢历史吗？\",\"你了解中国的哪些历史？\",\"历史对今天的生活有什么影响？\"],\n    \"学术研究\":[\"你做过学术研究吗？\",\"你觉得学术研究最重要的是什么？\",\"你关注过哪些学术问题？\"],\n    \"政策法规\":[\"你了解哪些公共政策？\",\"政策法规如何影响留学生活？\",\"你关注过教育政策吗？\"],\n    \"商业贸易\":[\"你了解国际贸易吗？\",\"电子商务给商业带来什么变化？\",\"你做过生意吗？\"],\n    \"国际事务\":[\"你关注哪些国际新闻？\",\"国际合作对世界有什么意义？\",\"你怎么看气候变化等全球问题？\"]\n  };\n\n  const levelQuestionGenerators = {\n    \"1\":[\n      t=>`你喜欢${t}吗？为什么？`,\n      t=>`你经常做什么和${t}有关的事？`,\n      t=>`请简单介绍一下${t}。`\n    ],\n    \"2\":[\n      t=>`你和家人或朋友常聊${t}吗？`,\n      t=>`请说说你最近一次和${t}有关的安排。`,\n      t=>`你觉得${t}怎么样？`\n    ],\n    \"3\":[\n      t=>`请分享一次和${t}有关的经历。`,\n      t=>`在你的国家，${t}有什么习惯或风俗？`,\n      t=>`学习或生活中，${t}对你有什么影响？`\n    ],\n    \"4\":[\n      t=>`请描述一下你所在社区的${t}情况。`,\n      t=>`你认为如何改善${t}？`,\n      t=>`${t}对你的健康或学习有什么影响？`\n    ],\n    \"5\":[\n      t=>`你和朋友之间有没有关于${t}的有趣故事？`,\n      t=>`在${t}方面你有什么好的方法或经验？`,\n      t=>`你如何看待现在的${t}现象？`\n    ],\n    \"6\":[\n      t=>`如果遇到和${t}有关的矛盾，你会怎么处理？`,\n      t=>`请比较一下中外在${t}方面的异同。`,\n      t=>`你最近关注过哪些和${t}有关的社会新闻？`\n    ],\n    \"7\":[\n      t=>`在专业或社交场合中，${t}有哪些礼仪或注意事项？`,\n      t=>`科学技术对${t}产生了什么影响？`,\n      t=>`请谈谈你对${t}的心理感受或情感体验。`\n    ],\n    \"8\":[\n      t=>`从历史或哲学角度看，${t}有什么意义？`,\n      t=>`语言文字与${t}有什么关系？`,\n      t=>`政治经济因素如何影响${t}？`\n    ],\n    \"9\":[\n      t=>`关于${t}，学术界有哪些重要研究或争议？`,\n      t=>`政策法规对${t}有哪些影响？`,\n      t=>`在国际事务或商业贸易中，${t}面临什么挑战？`\n    ]\n  };\n\n  /* ==================== 状态 ==================== */\n  const state = {\n    currentLevel:'1',\n    currentLevelName:'一级',\n    currentTopic:'个人信息',\n    history:[],\n    config:{\n      baseUrl:'https://api.openai.com/v1',\n      apiKey:'',\n      model:'gpt-4o-mini',\n      temperature:0.7,\n      systemPrompt:'',\n      openingMessage:'',\n      questions:[]\n    },\n    memory:{\n      turns:0,\n      userMessages:[],\n      corrections:[],\n      newWords:[]\n    }\n  };\n\n  let els = {};\n  let typingEl = null;\n\n  /* ==================== 工具函数 ==================== */\n  function escapeHtml(str){\n    return String(str).replace(/[&<>\"']/g, c=>({\n      '&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'\n    }[c]));\n  }\n  function toast(msg){\n    const t = document.getElementById('toast');\n    t.textContent = msg;\n    t.classList.add('show');\n    clearTimeout(t._timer);\n    t._timer = setTimeout(()=>t.classList.remove('show'), 2200);\n  }\n  function getLevelObj(level){\n    return LEVELS.find(l=>l.level===level) || LEVELS[0];\n  }\n  function getLevelName(level){\n    return getLevelObj(level).name;\n  }\n  function getTopics(level){\n    return getLevelObj(level).topics;\n  }\n\n  /* ==================== 规划生成 ==================== */\n  function generateSystemPrompt(level, levelName, topic){\n    return `你是国际中文教育领域的AI口语陪练，严格依据《国际中文教育中文水平等级标准》进行教学设计。\n当前学生水平：${levelName}（${level}级）\n当前话题：${topic}\n\n教学要求：\n1. 使用适合该等级的词汇和语法，尽量不超纲；如果遇到生词，可提供拼音或简短英文解释。\n2. 围绕“${topic}”进行互动对话，每次只提一个问题，耐心引导学生表达。\n3. 如果学生句子有语法、词汇或用词错误，先给出正确说法，再简短指出问题。\n4. 保持友好、鼓励、自然的语气。\n5. 优先使用中文回应，必要时可辅助少量英文。\n6. 回答长度控制在1-3句话，避免长篇讲解。`;\n  }\n\n  function generateOpening(levelName, topic){\n    return `你好！我是你的中文口语陪练 🎓\\n今天的话题是【${topic}】（${levelName}）。\\n你可以点击下面的问题开始，也可以直接输入你想说的话。`;\n  }\n\n  function generateQuestions(level, topic){\n    if(topicQuestions[topic]){\n      return [...topicQuestions[topic]];\n    }\n    const gens = levelQuestionGenerators[level] || levelQuestionGenerators['1'];\n    return gens.map(fn=>fn(topic));\n  }\n\n  /* ==================== 初始化 ==================== */\n  function init(){\n    els = {\n      levelSelect: document.getElementById('levelSelect'),\n      topicList: document.getElementById('topicList'),\n      headerTopic: document.getElementById('headerTopic'),\n      activeTopic: document.getElementById('activeTopic'),\n      activeLevel: document.getElementById('activeLevel'),\n      scenario: document.getElementById('scenario'),\n      messages: document.getElementById('messages'),\n      quickQuestions: document.getElementById('quickQuestions'),\n      userInput: document.getElementById('userInput'),\n      btnVoice: document.getElementById('btnVoice'),\n      btnSpeak: document.getElementById('btnSpeak'),\n      btnEvaluate: document.getElementById('btnEvaluate'),\n      btnSend: document.getElementById('btnSend'),\n      btnMemory: document.getElementById('btnMemory'),\n      btnClear: document.getElementById('btnClear'),\n      btnSettings: document.getElementById('btnSettings'),\n      memoryDrawer: document.getElementById('memoryDrawer'),\n      memoryContent: document.getElementById('memoryContent'),\n      btnCloseMemory: document.getElementById('btnCloseMemory'),\n      settingsModal: document.getElementById('settingsModal'),\n      btnCloseSettings: document.getElementById('btnCloseSettings'),\n      btnCloseSettings2: document.getElementById('btnCloseSettings2'),\n      btnSaveSettings: document.getElementById('btnSaveSettings'),\n      btnResetPrompt: document.getElementById('btnResetPrompt'),\n      overlay: document.getElementById('overlay'),\n      cfgBaseUrl: document.getElementById('cfgBaseUrl'),\n      cfgApiKey: document.getElementById('cfgApiKey'),\n      cfgModel: document.getElementById('cfgModel'),\n      cfgTemperature: document.getElementById('cfgTemperature'),\n      cfgSystem: document.getElementById('cfgSystem'),\n      cfgOpening: document.getElementById('cfgOpening'),\n      cfgQuestions: document.getElementById('cfgQuestions')\n    };\n\n    loadConfigFromStorage();\n    loadMemoryFromStorage();\n\n    // 填充等级选择\n    els.levelSelect.innerHTML = LEVELS.map(l=>`<option value=\"${l.level}\">${l.name}</option>`).join('');\n    els.levelSelect.value = state.currentLevel;\n    state.currentLevelName = getLevelName(state.currentLevel);\n\n    const firstTopics = getTopics(state.currentLevel);\n    if(!firstTopics.includes(state.currentTopic)){\n      state.currentTopic = firstTopics[0];\n    }\n\n    renderTopics();\n    bindEvents();\n    updateHeader();\n    startConversation();\n  }\n\n  function bindEvents(){\n    els.levelSelect.addEventListener('change', e=>{\n      state.currentLevel = e.target.value;\n      state.currentLevelName = getLevelName(state.currentLevel);\n      const topics = getTopics(state.currentLevel);\n      state.currentTopic = topics[0];\n      renderTopics();\n      updateHeader();\n      startConversation();\n    });\n\n    els.topicList.addEventListener('click', e=>{\n      const btn = e.target.closest('.topic-item');\n      if(!btn) return;\n      const topic = btn.dataset.topic;\n      if(topic === state.currentTopic) return;\n      state.currentTopic = topic;\n      renderTopics();\n      updateHeader();\n      startConversation();\n    });\n\n    els.quickQuestions.addEventListener('click', e=>{\n      const chip = e.target.closest('.chip');\n      if(!chip) return;\n      const q = chip.dataset.question;\n      if(q) sendMessage(q);\n    });\n\n    els.messages.addEventListener('click', e=>{\n      const btn = e.target.closest('.msg-speak');\n      if(!btn) return;\n      const bubble = btn.closest('.message')?.querySelector('.bubble');\n      if(bubble) speakText(bubble.innerText);\n    });\n\n    els.btnSend.addEventListener('click', ()=>sendMessage());\n    els.userInput.addEventListener('keydown', e=>{\n      if(e.key === 'Enter' && !e.shiftKey){\n        e.preventDefault();\n        sendMessage();\n      }\n    });\n    els.userInput.addEventListener('input', autoResize);\n    els.btnVoice.addEventListener('click', startVoiceInput);\n    els.btnSpeak.addEventListener('click', ()=>{\n      const text = getLastAssistantContent();\n      if(text) speakText(text);\n      else toast('暂无 AI 回复');\n    });\n    els.btnEvaluate.addEventListener('click', evaluateLast);\n    els.btnMemory.addEventListener('click', openMemoryDrawer);\n    els.btnClear.addEventListener('click', ()=>{\n      if(confirm('确定要开始新对话并清空当前记忆吗？')){\n        startConversation();\n        toast('已开始新对话');\n      }\n    });\n    els.btnSettings.addEventListener('click', openSettings);\n\n    els.btnCloseMemory.addEventListener('click', closeMemoryDrawer);\n    els.btnCloseSettings.addEventListener('click', closeSettings);\n    els.btnCloseSettings2.addEventListener('click', closeSettings);\n    els.btnSaveSettings.addEventListener('click', saveSettings);\n    els.btnResetPrompt.addEventListener('click', resetPlanning);\n    els.overlay.addEventListener('click', ()=>{\n      closeMemoryDrawer();\n      closeSettings();\n    });\n  }\n\n  function renderTopics(){\n    const topics = getTopics(state.currentLevel);\n    if(!topics.includes(state.currentTopic)){\n      state.currentTopic = topics[0];\n    }\n    els.topicList.innerHTML = topics.map(topic=>\n      `<button class=\"topic-item ${topic===state.currentTopic?'active':''}\" data-topic=\"${escapeHtml(topic)}\">${escapeHtml(topic)}</button>`\n    ).join('');\n  }\n\n  function updateHeader(){\n    els.activeTopic.textContent = state.currentTopic;\n    els.activeLevel.textContent = state.currentLevelName;\n    els.headerTopic.textContent = `${state.currentLevelName}·${state.currentTopic}`;\n    els.scenario.textContent = `围绕“${state.currentTopic}”进行${state.currentLevelName}水平对话实践。`;\n  }\n\n  function startConversation(){\n    state.history = [];\n    state.memory = {\n      turns:0,\n      userMessages:[],\n      corrections:[],\n      newWords:[]\n    };\n    state.config.systemPrompt = generateSystemPrompt(state.currentLevel, state.currentLevelName, state.currentTopic);\n    state.config.openingMessage = generateOpening(state.currentLevelName, state.currentTopic);\n    state.config.questions = generateQuestions(state.currentLevel, state.currentTopic);\n\n    els.messages.innerHTML = '';\n    addMessage('assistant', state.config.openingMessage);\n    renderQuickQuestions();\n    saveMemoryToStorage();\n    if(els.userInput) els.userInput.focus();\n  }\n\n  function renderQuickQuestions(){\n    const qs = state.config.questions.length ? state.config.questions : generateQuestions(state.currentLevel, state.currentTopic);\n    els.quickQuestions.innerHTML = `<span class=\"quick-title\">✨ 默认三个提问</span>` + qs.map(q=>\n      `<button class=\"chip\" data-question=\"${escapeHtml(q)}\">${escapeHtml(q)}</button>`\n    ).join('');\n  }\n\n  /* ==================== 消息管理 ==================== */\n  function addMessage(role, content){\n    state.history.push({role, content});\n    const msgDiv = document.createElement('div');\n    msgDiv.className = `message ${role}`;\n\n    const bubble = document.createElement('div');\n    bubble.className = 'bubble';\n    bubble.innerText = content;\n    msgDiv.appendChild(bubble);\n\n    if(role === 'assistant'){\n      const actions = document.createElement('div');\n      actions.className = 'msg-actions';\n      actions.innerHTML = `<button class=\"mini-btn msg-speak\">🔊 朗读</button>`;\n      msgDiv.appendChild(actions);\n    }\n\n    els.messages.appendChild(msgDiv);\n    scrollToBottom();\n  }\n\n  function scrollToBottom(){\n    requestAnimationFrame(()=>{\n      els.messages.scrollTop = els.messages.scrollHeight;\n    });\n  }\n\n  function showTyping(){\n    removeTyping();\n    typingEl = document.createElement('div');\n    typingEl.className = 'message assistant';\n    typingEl.innerHTML = `<div class=\"bubble typing\"><span></span><span></span><span></span></div>`;\n    els.messages.appendChild(typingEl);\n    scrollToBottom();\n  }\n\n  function removeTyping(){\n    if(typingEl){\n      typingEl.remove();\n      typingEl = null;\n    }\n  }\n\n  function autoResize(){\n    const el = els.userInput;\n    el.style.height = 'auto';\n    el.style.height = Math.min(el.scrollHeight, 130) + 'px';\n  }\n\n  /* ==================== 发送消息 & 模型调用 ==================== */\n  async function sendMessage(optionalText){\n    const text = (optionalText || els.userInput.value).trim();\n    if(!text) return;\n\n    els.userInput.value = '';\n    autoResize();\n\n    addMessage('user', text);\n    state.memory.turns += 1;\n    state.memory.userMessages.push(text);\n    saveMemoryToStorage();\n\n    showTyping();\n    const reply = await getAIReply(text);\n    removeTyping();\n    addMessage('assistant', reply);\n    saveMemoryToStorage();\n  }\n\n  async function getAIReply(userText){\n    if(!state.config.apiKey){\n      return demoReply(userText);\n    }\n    try{\n      const messages = [\n        {role:'system', content: state.config.systemPrompt || generateSystemPrompt(state.currentLevel, state.currentLevelName, state.currentTopic)},\n        ...state.history.slice(-20)\n      ];\n      const reply = await callOpenAI(messages, state.config.temperature);\n      return reply;\n    }catch(e){\n      toast('模型调用失败，已使用演示回复');\n      return demoReply(userText);\n    }\n  }\n\n  async function callOpenAI(messages, temperature = 0.7){\n    const base = state.config.baseUrl.replace(/\\/+$/,'');\n    const endpoint = `${base}/chat/completions`;\n    const resp = await fetch(endpoint, {\n      method:'POST',\n      headers:{\n        'Content-Type':'application/json',\n        'Authorization':'Bearer ' + state.config.apiKey\n      },\n      body:JSON.stringify({\n        model: state.config.model,\n        messages,\n        temperature: Number(temperature) || 0.7\n      })\n    });\n    if(!resp.ok){\n      const errText = await resp.text();\n      throw new Error(`HTTP ${resp.status}: ${errText.slice(0,200)}`);\n    }\n    const data = await resp.json();\n    if(!data.choices || !data.choices[0] || !data.choices[0].message){\n      throw new Error('响应格式错误');\n    }\n    return data.choices[0].message.content.trim();\n  }\n\n  function demoReply(userText){\n    const qs = state.config.questions.length ? state.config.questions : generateQuestions(state.currentLevel, state.currentTopic);\n    const replies = [\n      `我明白你的意思了。关于“${state.currentTopic}”，我还想问：${qs[0]}`,\n      `你说得很好！那么，${qs[1]}`,\n      `有意思！${qs[2]}`,\n      `谢谢你的分享。能不能再说得详细一点？`,\n      `很好！你可以试着用完整的句子再回答一次。`\n    ];\n    return replies[Math.floor(Math.random()*replies.length)];\n  }\n\n  /* ==================== 评估动作 ==================== */\n  async function evaluateLast(){\n    const userMsgs = state.history.filter(m=>m.role === 'user');\n    if(!userMsgs.length){\n      toast('请先输入至少一句话');\n      return;\n    }\n    const lastUser = userMsgs[userMsgs.length-1].content;\n    toast('评估中...');\n\n    let feedback = '';\n    try{\n      if(state.config.apiKey){\n        const system = `你是国际中文教育评估专家。请根据《国际中文教育中文水平等级标准》${state.currentLevelName}，对学生的句子进行简短反馈：先肯定优点，再指出1-2个可改进的语法或词汇问题（若无问题则说表达自然），最后给一个更地道的说法。用中文，不超过120字。`;\n        feedback = await callOpenAI([\n          {role:'system', content:system},\n          {role:'user', content:lastUser}\n        ], 0.3);\n      }else{\n        feedback = `【模拟评估】你的表达基本清楚。建议多使用完整句子，并注意“了”“的”“是……的”等结构。继续加油！`;\n      }\n      addMessage('assistant', `📝 评估反馈：${feedback}`);\n      state.memory.corrections.push({input:lastUser, feedback});\n      saveMemoryToStorage();\n    }catch(e){\n      toast('评估失败：' + e.message);\n    }\n  }\n\n  /* ==================== 语音与朗读 ==================== */\n  function speakText(text){\n    if(!window.speechSynthesis){\n      toast('当前浏览器不支持语音合成');\n      return;\n    }\n    const clean = String(text).replace(/📝 评估反馈：/g,'');\n    const utterance = new SpeechSynthesisUtterance(clean);\n    utterance.lang = 'zh-CN';\n    utterance.rate = 0.9;\n    speechSynthesis.cancel();\n    speechSynthesis.speak(utterance);\n  }\n\n  function getLastAssistantContent(){\n    const ass = state.history.filter(m=>m.role === 'assistant');\n    return ass.length ? ass[ass.length-1].content : '';\n  }\n\n  function startVoiceInput(){\n    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;\n    if(!SR){\n      toast('当前浏览器不支持语音识别，请使用 Chrome');\n      return;\n    }\n    const recognition = new SR();\n    recognition.lang = 'zh-CN';\n    recognition.interimResults = false;\n    recognition.maxAlternatives = 1;\n    recognition.onresult = function(e){\n      const transcript = e.results[0][0].transcript;\n      els.userInput.value = transcript;\n      autoResize();\n      toast('已识别：' + transcript);\n    };\n    recognition.onerror = function(e){\n      toast('语音识别失败：' + e.error);\n    };\n    recognition.start();\n  }\n\n  /* ==================== 记忆抽屉 ==================== */\n  function openMemoryDrawer(){\n    renderMemory();\n    els.memoryDrawer.classList.add('show');\n    els.overlay.classList.add('show');\n  }\n  function closeMemoryDrawer(){\n    els.memoryDrawer.classList.remove('show');\n    els.overlay.classList.remove('show');\n  }\n  function renderMemory(){\n    const m = state.memory;\n    const userList = (m.userMessages || []).map(msg=>`<li>${escapeHtml(msg)}</li>`).join('');\n    const corrList = (m.corrections || []).map(c=>`<li><strong>原句：</strong>${escapeHtml(c.input)}<br><strong>反馈：</strong>${escapeHtml(c.feedback)}</li>`).join('');\n    els.memoryContent.innerHTML = `\n      <div class=\"memory-stats\">\n        <span>📚 ${state.currentLevelName}</span>\n        <span>🗂️ ${state.currentTopic}</span>\n        <span>💬 轮数 ${m.turns || 0}</span>\n      </div>\n      <div class=\"memory-item\">\n        <h4>🗣️ 学生发言记录</h4>\n        <ul class=\"memory-list\">${userList || '<li>暂无记录</li>'}</ul>\n      </div>\n      <div class=\"memory-item\">\n        <h4>📝 评估反馈记录</h4>\n        <ul class=\"memory-list\">${corrList || '<li>暂无评估</li>'}</ul>\n      </div>\n      <div class=\"memory-item\">\n        <h4>🧩 新词积累</h4>\n        <ul class=\"memory-list\"><li>可在对话中让 AI 提取难词，此处展示新词表。</li></ul>\n      </div>\n      <button class=\"btn-outline\" id=\"btnExportMemory\" style=\"width:100%;\">⬇️ 导出记忆 JSON</button>\n    `;\n    const exportBtn = document.getElementById('btnExportMemory');\n    if(exportBtn) exportBtn.addEventListener('click', exportMemory);\n  }\n  function exportMemory(){\n    const data = {\n      level: state.currentLevel,\n      levelName: state.currentLevelName,\n      topic: state.currentTopic,\n      memory: state.memory,\n      exportedAt: new Date().toISOString()\n    };\n    const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = `AI中文实践记忆_${state.currentTopic}.json`;\n    a.click();\n    URL.revokeObjectURL(url);\n  }\n\n  /* ==================== 设置弹窗 ==================== */\n  function openSettings(){\n    els.cfgBaseUrl.value = state.config.baseUrl;\n    els.cfgApiKey.value = state.config.apiKey;\n    els.cfgModel.value = state.config.model;\n    els.cfgTemperature.value = state.config.temperature;\n    els.cfgSystem.value = state.config.systemPrompt || generateSystemPrompt(state.currentLevel, state.currentLevelName, state.currentTopic);\n    els.cfgOpening.value = state.config.openingMessage || generateOpening(state.currentLevelName, state.currentTopic);\n    els.cfgQuestions.value = (state.config.questions && state.config.questions.length ? state.config.questions : generateQuestions(state.currentLevel, state.currentTopic)).join('\\n');\n    els.settingsModal.classList.add('show');\n    els.overlay.classList.add('show');\n  }\n  function closeSettings(){\n    els.settingsModal.classList.remove('show');\n    els.overlay.classList.remove('show');\n  }\n  function saveSettings(){\n    const baseUrl = els.cfgBaseUrl.value.trim() || 'https://api.openai.com/v1';\n    const apiKey = els.cfgApiKey.value.trim();\n    const model = els.cfgModel.value.trim() || 'gpt-4o-mini';\n    const temperature = parseFloat(els.cfgTemperature.value) || 0.7;\n\n    state.config.baseUrl = baseUrl;\n    state.config.apiKey = apiKey;\n    state.config.model = model;\n    state.config.temperature = Math.min(Math.max(temperature, 0), 1.5);\n\n    state.config.systemPrompt = els.cfgSystem.value.trim();\n    state.config.openingMessage = els.cfgOpening.value.trim();\n    const questions = els.cfgQuestions.value.split('\\n').map(s=>s.trim()).filter(Boolean).slice(0,3);\n    state.config.questions = questions.length ? questions : generateQuestions(state.currentLevel, state.currentTopic);\n\n    saveConfigToStorage();\n    renderQuickQuestions();\n    closeSettings();\n    toast('设置已保存 ✅');\n  }\n  function resetPlanning(){\n    state.config.systemPrompt = generateSystemPrompt(state.currentLevel, state.currentLevelName, state.currentTopic);\n    state.config.openingMessage = generateOpening(state.currentLevelName, state.currentTopic);\n    state.config.questions = generateQuestions(state.currentLevel, state.currentTopic);\n    els.cfgSystem.value = state.config.systemPrompt;\n    els.cfgOpening.value = state.config.openingMessage;\n    els.cfgQuestions.value = state.config.questions.join('\\n');\n    toast('已恢复默认规划内容');\n  }\n\n  /* ==================== 本地存储 ==================== */\n  function loadConfigFromStorage(){\n    try{\n      const saved = localStorage.getItem('aiChineseConfig');\n      if(saved){\n        const c = JSON.parse(saved);\n        if(c && typeof c === 'object'){\n          if(typeof c.baseUrl === 'string') state.config.baseUrl = c.baseUrl;\n          if(typeof c.apiKey === 'string') state.config.apiKey = c.apiKey;\n          if(typeof c.model === 'string') state.config.model = c.model;\n          if(typeof c.temperature === 'number') state.config.temperature = c.temperature;\n        }\n      }\n    }catch(e){}\n  }\n  function saveConfigToStorage(){\n    try{\n      localStorage.setItem('aiChineseConfig', JSON.stringify({\n        baseUrl: state.config.baseUrl,\n        apiKey: state.config.apiKey,\n        model: state.config.model,\n        temperature: state.config.temperature\n      }));\n    }catch(e){}\n  }\n  function loadMemoryFromStorage(){\n    try{\n      const saved = localStorage.getItem('aiChineseMemory');\n      if(saved){\n        const m = JSON.parse(saved);\n        if(m && typeof m === 'object'){\n          state.memory = {\n            turns: m.turns || 0,\n            userMessages: Array.isArray(m.userMessages) ? m.userMessages : [],\n            corrections: Array.isArray(m.corrections) ? m.corrections : [],\n            newWords: Array.isArray(m.newWords) ? m.newWords : []\n          };\n        }\n      }\n    }catch(e){}\n  }\n  function saveMemoryToStorage(){\n    try{\n      localStorage.setItem('aiChineseMemory', JSON.stringify(state.memory));\n    }catch(e){}\n  }\n\n  /* ==================== 启动 ==================== */\n  document.addEventListener('DOMContentLoaded', init);\n</script>\n</body>\n</html>\n```","model":"deepseek-v4-pro","modelName":"教学助手","modelIdx":0,"timestamp":1788319756,"followUps":["如果我想把大模型换成 DeepSeek 或通义千问，接口地址和模型名应该怎么改？","可以增加“AI 自动提取生词并加入记忆本”的功能吗？","如何部署这个网页，让手机也能访问，并且保护 API Key 不被泄露？","能否根据《国际中文教育中文水平等级标准》的量化指标自动给学生当前表现打分或定级？","可以增加老师端或班级管理功能，记录每个学生的练习记录吗？"]}},"currentId":"1dafd672-3793-4263-b0b1-7c451935d8fe"},"files":[],"timestamp":1788319756633,"ai_context":{"surface":"ai_assistant","app_id":"ai-assistant","task_type":"teaching_qa","source_required":false,"content_revision":2}}
```