一、开场:为什么要做这个

上周三晚上十点,我正戴着眼镜在工位上改 bug,邮箱弹出一封新邮件:「您投递的前端工程师岗位已通过初筛,明天上午十点视频面试。」

简历是半个月前海投的,项目经验那栏我自己都快忘了写了什么。打开手机想刷两道题——解锁、找 APP、打字,等我点开第一题,眼皮已经开始打架。

那时候脑子里冒出来一个念头:与其低头打字,不如让眼镜直接当面试官。我说它听,我答它评,手不用碰任何东西。周末就把这个「面试陪练」搓出来了,这篇文章记一下怎么做的。

二、它能干什么

打开首页,四个岗位芯片:前端、后端、嵌入式、产品。下面四个难度:新手、初级、中级、高级。滑到哪个亮哪个。不想用预设的也行,直接对着眼镜说「我要面 Java 后端」,它就把这六个字当岗位名带进去。

点开始,进对话页。这个页面和前两个应用长得完全不一样——没有列表、没有卡片,就是聊天气泡。我说的话靠右,绿边黑底;AI 说的话靠左,绿字,左边一条小绿线。AI 出题的时候一个字一个字往外蹦。

最多聊五题。答完第五题,或者说一句「结束面试」,跳到复盘页。它把整场对话整理成四块:整体评价、答得好的、薄弱点、建议补强。然后念给你听。之前面过的场次都存在本地,按时间倒序排着,点进去能回看完整对话。

也可以不碰屏幕直接说:「练一场前端初级」直接进对话页,「看历史」打开历史页。宿主大模型负责听懂这些话,页面负责真正的问答。

三、多轮对话怎么管

前两个应用都是问一句答一句就结束,这次是来回聊。AI 得记得你上一句说了什么,才能追问下一题。核心就是一个数组,每条消息记三个字段:谁说的、说了什么、什么时候说的。

const MAX_CHARS = 6000;
const KEEP_ROUNDS = 6;
const KEEP_MESSAGES = KEEP_ROUNDS * 2;

let messages = [];

function charLen(list) {
  let n = 0;
  for (const item of list || []) {
    n += item && item.content ? String(item.content).length : 0;
  }
  return n;
}

// 超 6000 字只留最近 6 轮,发给模型;完整记录仍在 messages 里
export function limitForModel(list) {
  const src = Array.isArray(list) ? list.slice() : [];
  if (charLen(src) <= MAX_CHARS) return src;
  if (src.length > KEEP_MESSAGES) return src.slice(src.length - KEEP_MESSAGES);
  return src;
}

export function pushMessage(role, content) {
  const text = String(content || '').trim();
  if (!text) return getMessages();
  messages.push({
    role: role === 'assistant' ? 'assistant' : 'user',
    content: text,
    ts: Date.now()
  });
  return getMessages();
}

有个细节:发给大模型的第一句是「请直接出第一题」,这句不能存进历史记录——不然复盘的时候会出现一句莫名其妙的指令。只在请求那一刻拼上去,不 push 进 messages。完整对话留着写复盘用,发给模型的上下文才裁剪。

四、面试官的"人设"怎么写

大模型本身不知道自己是面试官,得在 system prompt 里把规矩讲清楚。岗位和难度是首页选好的,拼进提示词:

export function buildSystemPrompt(role, level) {
  return [
    '你是一位严格但友好的技术面试官。岗位:' + role + ',难度:' + level + '。',
    '规则:',
    '1. 一次只问一个问题,不要一次问多个',
    '2. 根据用户的回答简短点评(2-3句话),然后出下一题',
    '3. 新手出概念题,初级出实战题,中级出场景题,高级出系统设计题',
    '4. 不要自己说"我们开始面试",直接出题',
    '5. 用户说"结束面试"时,回复"面试结束,正在生成复盘"',
    '6. 每次回复不超过80个字,方便眼镜阅读和语音播报,不要用 markdown'
  ].join('\n');
}

出题先走 DeepSeek。超时 10 秒、HTTP 报错、返回空、JSON 解析失败,都静默切本地题库。本地题库按岗位和难度分,前端、后端、嵌入式、产品、通用各 16 题,一共 80 题,每个岗位每个难度 4 道。自定义岗位对不上这四类,就从通用题里抽。

// 优先同岗同难度未出过的;没有了同岗其他难度;再没有就通用题
export function pickQuestion(role, level, used) {
  const key = resolveRole(role);
  const lv = resolveLevel(level);
  const usedSet = toSet(used);
  let pool = BANK.filter(i => i.role === key && i.level === lv && !usedSet.has(i.id));
  if (!pool.length) pool = BANK.filter(i => i.role === key && !usedSet.has(i.id));
  if (!pool.length) pool = BANK.filter(i => i.role === '通用' && !usedSet.has(i.id));
  return pool[Math.floor(Math.random() * pool.length)];
}

本地点评不走 AI,按关键词匹配:你的回答里提到了这道题的 goodPoints 里的词,就说「答到了XX」;没提到,就说「可以补充YY」。页面右上角有个小徽章,显示「AI 在线」或「本地题库」,一眼就知道现在跑的是哪条路。用户中途退出触发的中止请求不算失败,不能因此降级——不然你刚答完一题准备说下一句,它突然切到本地题库,体验很怪。

五、对话界面怎么渲染

对话页没有按钮栏、没有列表,就两列气泡。用户消息靠右,荧光绿边框,最大宽度七成;AI 消息靠左,纯绿字,没框,左边一条 4px 绿条。两个气泡间距 6px,新消息从底部顶上来。

<scroll-view class="scroll" scroll-y="true">  <view ink:for="{{ bubbles }}" ink:key="id" id="{{ item.anchor }}"        class="{{ item.rowClass }}">    <view class="bar" ink:if="{{ item.showBar }}"></view>    <view class="{{ item.bubbleClass }}">      <view class="dots" ink:if="{{ item.showDots }}">        <text class="dot d1">.</text>        <text class="dot d2">.</text>        <text class="dot d3">.</text>      </view>      <text class="{{ item.textClass }}" ink:if="{{ item.showText }}">        {{ item.content }}      </text>    </view>  </view></scroll-view>

AI 还在想的时候,气泡里显示三个跳动的点,不是转圈图标。打字机效果配合出来,就是它边想边说的感觉。

焦点系统上也踩了坑。首页有 11 个可聚焦项:4 个岗位、4 个难度、自定义、开始、历史。默认已经选了「前端」和「初级」,如果用同一种亮绿色表示「已选中」和「当前焦点」,屏幕上会同时亮三个。后来拆成两套样式:已选中用暗绿加细线,焦点用亮绿加粗。滑到已选中的那项时,只保留焦点样式。

六、AI 回答怎么做到打字机效果

这是体验上最爽的一点。DeepSeek 支持流式输出,不用等它想完整句再一次性显示。用 wx.request 发请求时开 enableChunked,监听 onChunkReceived。每个 chunk 可能是 ArrayBuffer,要用 TextDecoder 解码成字符串,再按 SSE 的 data: 行拆,取 delta.content。

task = wx.request({
  url: CONFIG.ENDPOINT,
  method: 'POST',
  enableChunked: true,
  dataType: 'text',
  header: { Authorization: 'Bearer ' + CONFIG.DEEPSEEK_API_KEY },
  data: {
    model: CONFIG.MODEL,
    thinking: { type: 'disabled' },
    temperature: CONFIG.TEMPERATURE,
    max_tokens: CONFIG.MAX_TOKENS,
    stream: true,
    messages: messages
  },
  success(res) { /* ... */ }
});

task.onChunkReceived((chunk) => {
  const text = decodeChunk(chunk);  // ArrayBuffer -> string
  rest += text;
  const lines = rest.split('\n');
  rest = lines.pop();
  for (const line of lines) {
    const piece = deltaFromLine(line);  // 解析 data: {...}
    if (piece) takeDelta(piece);        // 逐字塞进打字机队列
  }
});

打字机本身是个小队列,每 16ms 吐一个字到当前 AI 气泡。万一流式不支持,降级成普通请求,拿到完整回复后用同样的队列逐字显示,视觉效果一样只是慢一点。TTS 也跟着走:AI 的回答按句切,每句不超过 50 字,排队念出来。

七、面试结束:复盘报告怎么生成

答完五题,把整场对话重新拼一遍发给 DeepSeek,要求只返回 JSON:

export async function createReview(session) {
  if (session.source === 'local') return localReport(session);
  const messages = limitForModel(session.messages || []);
  const prompt = [
    '请根据以下面试对话,生成一份结构化复盘报告,输出 JSON:',
    '{"summary":"整体表现一句话","goodPoints":["答得好的点"],',
    '"weakPoints":["薄弱点"],"suggestions":["建议补强方向"]}',
    '对话记录:',
    formatDialog(messages)
  ].join('\n');
  try {
    const content = await requestJson({
      messages: [
        { role: 'system', content: '只输出 JSON 对象,不要 markdown,不要解释。' },
        { role: 'user', content: prompt }
      ]
    });
    return parseReport(content);
  } catch (err) {
    return localReport(session);
  }
}

parseReport 做了 JSON 容错:如果大模型在 JSON 前后多写了字,就从第一个 { 到最后一个 } 之间截取再 parse。本地题库模式不发请求,把这五题的 goodPoints 和 weakPoints 拼一份简单复盘。每场面试存在本地 Storage,key 是 interview_sessions_v1,最多留 30 场,按时间倒序。

八、工程初始化与发布

config.js 就几样东西:

export const CONFIG = {
  DEEPSEEK_API_KEY: 'sk-你的key',
  ENDPOINT: 'https://api.deepseek.com/v1/chat/completions',
  MODEL: 'deepseek-v4-flash',
  TIMEOUT_MS: 10000,
  TEMPERATURE: 0.7,
  MAX_TOKENS: 400,
  REVIEW_MAX_TOKENS: 512
};

四个页面:home 选岗选难度,chat 聊天气泡,review 复盘,history 历史记录。六个模块:interviewer.js 出题、stream.js 请求和打字机、local-bank.js 题库、chat-history.js 上下文、review.js 复盘、storage.js 存取。

AGENTS.md 里定义了宿主大模型怎么开页面:说「练一场前端初级」直接进对话页,说「我要面试」但没说岗位就打开首页,说「看历史」打开历史页。已经在对话页时宿主不再抢答,出题和点评都交给页面。

在 AIUI Studio 创建智能体后,把工程目录导入 Craft,右侧麦克风就能模拟说话。调试好了打包提审,记得传 3-5 张截图和 aix 包。

九、运行效果

运行效果如下:

十、踩过的坑

首页同时亮了三处。默认选了「前端」和「初级」,滑动焦点停在「产品」,三个位置用同一种亮绿,看起来像三个都选中了。解法是把「已选中」和「当前焦点」拆成两套样式:选中用暗绿加细线,焦点用亮绿加粗,两者重叠时只保留焦点样式。

function buildHome(state, listening) {
  const focus = state.focus;
  const roles = [];
  for (let i = 0; i < ROLES.length; i++) {
    const on = !state.customOn && state.roleIndex === i;
    const focused = focus === i;
    let boxClass = 'chip';
    if (on && !focused) boxClass += ' chip-on';   // 已选中:暗绿细线
    if (focused) boxClass += ' chip-focus';        // 焦点:亮绿加粗
    roles.push({
      name: ROLES[i],
      boxClass: boxClass,
      textClass: focused ? 't-focus' : (on ? 't-pick' : 't-off')
    });
  }
  // 难度行同理...
}
.chip-on   { border-bottom-color: rgba(64,255,94,0.42); }
.chip-focus { border-bottom-width: 2px; border-bottom-color: #40FF5E; }
.t-pick  { color: rgba(64,255,94,0.55); }   /* 已选中:暗绿 */
.t-focus { color: #40FF5E; font-weight: 700; } /* 焦点:亮绿加粗 */

对话页没有退出入口。最初设计是按退格结束,但戴眼镜时根本想不起来还有退格这回事。后来在右下角加了「退出」按钮,滑到它上面会变成亮绿粗边浅底,单击才退出。回放历史时这个按钮自动变成「返回」,不会误生成复盘。

<view bindtap="onExitTap" class="{{ exitClass }}">  &lt;text class="{{ exitTextClass }}"&gt;{{ exitLabel }}&lt;/text&gt;&lt;/view&gt;</view>
onExitTap() {
  this.setFocus(1);
  if (this._replay || this._finishing) this.goBack();  // 回放时只返回
  else this.finishInterview();                         // 正式面试才生成复盘
}

滑动看起来没反应。一开始把滑动理解成滚动对话,但屏幕上就一道短题,根本没超出可视区,滚动条纹丝不动。而且模拟器的前后滑动发的是方向键,如果页面只处理上下不处理左右,手势就被吃了。四个方向键都要认。按键抬起时要 preventDefault,不然宿主会自己处理退格返回和方向键滚动。

slideDir(event) {
  const code = (event && (event.code || event.key)) || '';
  if (code === 'ArrowUp' || code === 'ArrowLeft' ||
      code === 'Up' || code === 'Left') return -1;
  if (code === 'ArrowDown' || code === 'ArrowRight' ||
      code === 'Down' || code === 'Right') return 1;
  return 0;
}

onKeyUp(event) {
  try { event.preventDefault(); } catch (e) {}  // 必须在抬起时拦
  const code = event && event.code;
  if (code === 'Backspace') { this.finishInterview(); return; }
  if (this.onSlide(event)) return;
  if (code === 'Enter') this.onEnterUp();
}

退出按钮没有高光。按钮一直长一个样,滑动也不高亮,用户根本不知道现在单击会触发说话还是退出。解法是在说话提示和退出按钮之间切焦点,滑到退出时才变亮绿粗边。

setFocus(index) {
  const i = index ? 1 : 0;
  this._focus = i;
  this.setData({
    exitClass: i === 1 ? 'exit exit-on' : 'exit',
    exitTextClass: i === 1 ? 'exit-text exit-text-on' : 'exit-text',
    hintClass: i === 1 ? 'hint' : 'hint hint-on'
  });
}
.exit      { border: 1px solid rgba(64,255,94,0.28); }
.exit-on   { border: 2px solid #40FF5E; background: rgba(64,255,94,0.18); }
.exit-text { color: rgba(64,255,94,0.4); }
.exit-text-on { color: #40FF5E; font-weight: 700; }

十一、写在最后

这个应用和前两个最大的区别在于:前两个是「录一句话存下来」,这个是「你一句我一句来回聊」。多轮上下文管理和流式输出是两个真正不同的技术点。

下次临时收到面试通知,不用掏手机了。戴上眼镜,说一句「帮我练十分钟前端面试」就行。

程序源码:https://gitcode.com/qq_52611326/mianshi

更多推荐