求职面试可能会让人感到压力,而练习是建立信心的关键。虽然与朋友或导师进行的传统模拟面试很有价值,但并非总能在需要时获得。在本教程中,您将学习如何构建一个由 AI 驱动的面试练习工具,该工具可提供实时反馈以帮助提高面试技巧。
在本教程结束时,您将构建一个完整的面试练习工具,具有以下核心功能:
- 使用 WebSocket 连接的实时面试模拟工具
- 将音频转换为文本的 AI 驱动的语音处理管道
- 提供类似面试官互动的智能响应系统
- 使用 Durable Objects 管理面试会话和历史记录的持久存储系统
All of the tutorials assume you have already completed the Get started guide, which gets you set up with a Cloudflare Workers account, C3 ↗, and Wrangler.
- Sign up for a Cloudflare account ↗.
- Install
Node.js
↗.
Node.js version manager
Use a Node version manager like Volta ↗ or nvm ↗ to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0
or later.
本教程演示了如何使用多个 Cloudflare 产品,虽然许多功能在免费套餐中可用,但 Workers AI 的某些组件可能会产生基于使用量的费用。在继续之前,请查看 Workers AI 的定价文档。
使用 Create Cloudflare CLI (C3) 工具和 Hono 框架创建一个 Cloudflare Workers 项目。
通过运行以下命令创建一个新的 Worker 项目,使用 ai-interview-tool
作为 Worker 名称:
npm create cloudflare@latest -- ai-interview-tool
yarn create cloudflare ai-interview-tool
pnpm create cloudflare@latest ai-interview-tool
For setup, select the following options:
- For What would you like to start with?, choose
Framework Starter
. - For Which development framework do you want to use?, choose
Hono
. - Complete the framework's own CLI wizard.
- For Do you want to use git for version control?, choose
Yes
. - For Do you want to deploy your application?, choose
No
(we will be making some changes before deploying).
要在本地开发和测试您的 Cloudflare Workers 应用程序:
- 在您的终端中导航到您的 Workers 项目目录:
cd ai-interview-tool
- 通过运行以下命令启动开发服务器:
npx wrangler dev
当您运行 wrangler dev
时,该命令会启动一个本地开发服务器,并提供一个 localhost
URL,您可以在其中预览您的应用程序。
您现在可以对代码进行更改,并在提供的 localhost 地址上实时查看它们。
项目设置好后,创建将构成面试系统基础的 TypeScript 类型。这些类型将帮助您维护类型安全,并为应用程序的不同组件提供清晰的接口。
创建一个新的 types.ts
文件,其中将包含以下内容的基本类型和枚举:
- 可以评估的面试技能(JavaScript、React 等)
- 不同的面试职位(初级开发人员、高级开发人员等)
- 面试状态跟踪
- 用户与 AI 之间的消息处理
- 核心面试数据结构
import { Context } from "hono";
// API 端点的上下文类型,包括环境绑定和用户信息export interface ApiContext { Bindings: CloudflareBindings; Variables: { username: string; };}
export type HonoCtx = Context<ApiContext>;
// 您可以在模拟面试期间评估的技术技能列表。// 此应用程序侧重于在真实面试中通常测试的流行 Web 技术和编程语言。export enum InterviewSkill { JavaScript = "JavaScript", TypeScript = "TypeScript", React = "React", NodeJS = "NodeJS", Python = "Python",}
// 基于不同工程职位的可用面试类型。// 这有助于根据候选人的目标职位定制面试体验和问题。export enum InterviewTitle { JuniorDeveloper = "初级开发人员面试", SeniorDeveloper = "高级开发人员面试", FullStackDeveloper = "全栈开发人员面试", FrontendDeveloper = "前端开发人员面试", BackendDeveloper = "后端开发人员面试", SystemArchitect = "系统架构师面试", TechnicalLead = "技术主管面试",}
// 跟踪面试会话的当前状态。// 这将帮助您管理面试流程,并在流程的每个阶段显示适当的 UI/操作。export enum InterviewStatus { Created = "created", // 面试已创建但未开始 Pending = "pending", // 等待面试官/系统 InProgress = "in_progress", // 进行中的面试会话 Completed = "completed", // 面试成功完成 Cancelled = "cancelled", // 面试提前终止}
// 定义在面试聊天中发送消息的人export type MessageRole = "user" | "assistant" | "system";
// 面试期间交换的单个消息的结构export interface Message { messageId: string; // 消息的唯一标识符 interviewId: string; // 将消息链接到特定面试 role: MessageRole; // 谁发送了消息 content: string; // 实际消息内容 timestamp: number; // 消息发送时间}
// 保存有关面试会话的所有信息的主要数据结构。// 这包括元数据、交换的消息和当前状态。export interface InterviewData { interviewId: string; title: InterviewTitle; skills: InterviewSkill[]; messages: Message[]; status: InterviewStatus; createdAt: number; updatedAt: number;}
// 创建新面试会话的输入格式。// 简化接口,接受开始面试所需的基本参数。export interface InterviewInput { title: string; skills: string[];}
接下来,设置自定义错误类型以处理应用程序中可能发生的不同类型的错误。这包括:
- 数据库错误(例如,连接问题、查询失败)
- 与面试相关的错误(例如,无效输入、转录失败)
- 身份验证错误(例如,无效会话)
创建以下 errors.ts
文件:
export const ErrorCodes = { INVALID_MESSAGE: "INVALID_MESSAGE", TRANSCRIPTION_FAILED: "TRANSCRIPTION_FAILED", LLM_FAILED: "LLM_FAILED", DATABASE_ERROR: "DATABASE_ERROR",} as const;
export class AppError extends Error { constructor( message: string, public statusCode: number, ) { super(message); this.name = this.constructor.name; }}
export class UnauthorizedError extends AppError { constructor(message: string) { super(message, 401); }}
export class BadRequestError extends AppError { constructor(message: string) { super(message, 400); }}
export class NotFoundError extends AppError { constructor(message: string) { super(message, 404); }}
export class InterviewError extends Error { constructor( message: string, public code: string, public statusCode: number = 500, ) { super(message); this.name = "InterviewError"; }}
在此步骤中,您将实现一个基本的身份验证系统,以跟踪和识别与您的 AI 面试练习工具交互的用户。该系统使用仅 HTTP 的 cookie 来存储用户名,使您能够识别请求发送者及其相应的 Durable Object。这种直接的身份验证方法要求用户提供一个用户名,然后将其安全地存储在 cookie 中。这种方法使您能够:
- 跨请求识别用户
- 将面试会话与特定用户关联
- 保护对与面试相关的端点的访问
创建一个中间件函数,用于检查是否存在有效的身份验证 cookie。此中间件将用于保护需要身份验证的路由。
创建一个新的中间件文件 middleware/auth.ts
:
import { Context } from "hono";import { getCookie } from "hono/cookie";import { UnauthorizedError } from "../errors";
export const requireAuth = async (ctx: Context, next: () => Promise<void>) => { // Get username from cookie const username = getCookie(ctx, "username");
if (!username) { throw new UnauthorizedError("User is not logged in"); }
// Make username available to route handlers ctx.set("username", username); await next();};
This middleware:
- Checks for a
username
cookie - Throws an
Error
if the cookie is missing - Makes the username available to downstream handlers via the context
Next, create the authentication routes that will handle user login. Create a new file routes/auth.ts
:
import { Context, Hono } from "hono";import { setCookie } from "hono/cookie";import { BadRequestError } from "../errors";import { ApiContext } from "../types";
export const authenticateUser = async (ctx: Context) => { // Extract username from request body const { username } = await ctx.req.json();
// Make sure username was provided if (!username) { throw new BadRequestError("Username is required"); }
// Create a secure cookie to track the user's session // This cookie will: // - Be HTTP-only for security (no JS access) // - Work across all routes via path="/" // - Last for 24 hours // - Only be sent in same-site requests to prevent CSRF setCookie(ctx, "username", username, { httpOnly: true, path: "/", maxAge: 60 * 60 * 24, sameSite: "Strict", });
// Let the client know login was successful return ctx.json({ success: true });};
// Set up authentication-related routesexport const configureAuthRoutes = () => { const router = new Hono<ApiContext>();
// POST /login - Authenticate user and create session router.post("/login", authenticateUser);
return router;};
Finally, update main application file to include the authentication routes. Modify src/index.ts
:
import { configureAuthRoutes } from "./routes/auth";import { Hono } from "hono";import { logger } from "hono/logger";import type { ApiContext } from "./types";import { requireAuth } from "./middleware/auth";
// Create our main Hono app instance with proper typingconst app = new Hono<ApiContext>();
// Create a separate router for API endpoints to keep things organizedconst api = new Hono<ApiContext>();
// Set up global middleware that runs on every request// - Logger gives us visibility into what is happeningapp.use("*", logger());
// Wire up all our authentication routes (login, etc)// These will be mounted under /api/v1/auth/api.route("/auth", configureAuthRoutes());
// Mount all API routes under the version prefix (for example, /api/v1)// This allows us to make breaking changes in v2 without affecting v1 usersapp.route("/api/v1", api);
export default app;
Now we have a basic authentication system that:
- Provides a login endpoint at
/api/v1/auth/login
- Securely stores the username in a cookie
- Includes middleware to protect authenticated routes
Now that you have your authentication system in place, create a Durable Object to manage interview sessions. Durable Objects are perfect for this interview practice tool because they provide the following functionalities:
- Maintains states between connections, so users can reconnect without losing progress.
- Provides a SQLite database to store all interview Q&A, feedback and metrics.
- Enables smooth real-time interactions between the interviewer AI and candidate.
- Handles multiple interview sessions efficiently without performance issues.
- Creates a dedicated instance for each user, giving them their own isolated environment.
First, you will need to configure the Durable Object in Wrangler file. Add the following configuration:
[[durable_objects.bindings]]name = "INTERVIEW"class_name = "Interview"
[[migrations]]tag = "v1"new_sqlite_classes = ["Interview"]
Next, create a new file interview.ts
to define our Interview Durable Object:
import { DurableObject } from "cloudflare:workers";
export class Interview extends DurableObject<CloudflareBindings> { // We will use it to keep track of all active WebSocket connections for real-time communication private sessions: Map<WebSocket, { interviewId: string }>;
constructor(state: DurableObjectState, env: CloudflareBindings) { super(state, env);
// Initialize empty sessions map - we will add WebSocket connections as users join this.sessions = new Map(); }
// Entry point for all HTTP requests to this Durable Object // This will handle both initial setup and WebSocket upgrades async fetch(request: Request) { // For now, just confirm the object is working // We'll add WebSocket upgrade logic and request routing later return new Response("Interview object initialized"); }
// Broadcasts a message to all connected WebSocket clients. private broadcast(message: string) { this.ctx.getWebSockets().forEach((ws) => { try { if (ws.readyState === WebSocket.OPEN) { ws.send(message); } } catch (error) { console.error( "Error broadcasting message to a WebSocket client:", error, ); } }); }}
Now we need to export the Durable Object in our main src/index.ts
file:
import { Interview } from "./interview";
// ... previous code ...
export { Interview };
export default app;
Since the Worker code is written in TypeScript, you should run the following command to add the necessary type definitions:
npm run cf-typegen
Now you will use SQLite at the Durable Object level for data persistence. This gives each user their own isolated database instance. You will need two main tables:
interviews
: Stores interview session datamessages
: Stores all messages exchanged during interviews
Before you create these tables, create a service class to handle your database operations. This encapsulates database logic and helps you:
- Manage database schema changes
- Handle errors consistently
- Keep database queries organized
Create a new file called services/InterviewDatabaseService.ts
:
import { InterviewData, Message, InterviewStatus, InterviewTitle, InterviewSkill,} from "../types";import { InterviewError, ErrorCodes } from "../errors";
const CONFIG = { database: { tables: { interviews: "interviews", messages: "messages", }, indexes: { messagesByInterview: "idx_messages_interviewId", }, },} as const;
export class InterviewDatabaseService { constructor(private sql: SqlStorage) {}
/** * Sets up the database schema by creating tables and indexes if they do not exist. * This is called when initializing a new Durable Object instance to ensure * we have the required database structure. * * The schema consists of: * - interviews table: Stores interview metadata like title, skills, and status * - messages table: Stores the conversation history between user and AI * - messages index: Helps optimize queries when fetching messages for a specific interview */ createTables() { try { // Get list of existing tables to avoid recreating them const cursor = this.sql.exec(`PRAGMA table_list`); const existingTables = new Set([...cursor].map((table) => table.name));
// The interviews table is our main table storing interview sessions. // We only create it if it does not exist yet. if (!existingTables.has(CONFIG.database.tables.interviews)) { this.sql.exec(InterviewDatabaseService.QUERIES.CREATE_INTERVIEWS_TABLE); }
// The messages table stores the actual conversation history. // It references interviews table via foreign key for data integrity. if (!existingTables.has(CONFIG.database.tables.messages)) { this.sql.exec(InterviewDatabaseService.QUERIES.CREATE_MESSAGES_TABLE); }
// Add an index on interviewId to speed up message retrieval. // This is important since we will frequently query messages by interview. this.sql.exec(InterviewDatabaseService.QUERIES.CREATE_MESSAGE_INDEX); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); throw new InterviewError( `Failed to initialize database: ${message}`, ErrorCodes.DATABASE_ERROR, ); } }
private static readonly QUERIES = { CREATE_INTERVIEWS_TABLE: ` CREATE TABLE IF NOT EXISTS interviews ( interviewId TEXT PRIMARY KEY, title TEXT NOT NULL, skills TEXT NOT NULL, createdAt INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000), updatedAt INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000), status TEXT NOT NULL DEFAULT 'pending' ) `, CREATE_MESSAGES_TABLE: ` CREATE TABLE IF NOT EXISTS messages ( messageId TEXT PRIMARY KEY, interviewId TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, timestamp INTEGER NOT NULL, FOREIGN KEY (interviewId) REFERENCES interviews(interviewId) ) `, CREATE_MESSAGE_INDEX: ` CREATE INDEX IF NOT EXISTS idx_messages_interview ON messages(interviewId) `, };}
Update the Interview
Durable Object to use the database service by modifying src/interview.ts
:
import { InterviewDatabaseService } from "./services/InterviewDatabaseService";
export class Interview extends DurableObject<CloudflareBindings> { // Database service for persistent storage of interview data and messages private readonly db: InterviewDatabaseService; private sessions: Map<WebSocket, { interviewId: string }>;
constructor(state: DurableObjectState, env: CloudflareBindings) { // ... previous code ... // Set up our database connection using the DO's built-in SQLite instance this.db = new InterviewDatabaseService(state.storage.sql); // First-time setup: ensure our database tables exist // This is idempotent so safe to call on every instantiation this.db.createTables(); }}
Add methods to create and retrieve interviews in services/InterviewDatabaseService.ts
:
export class InterviewDatabaseService { /** * Creates a new interview session in the database. * * This is the main entry point for starting a new interview. It handles all the * initial setup like: * - Generating a unique ID using crypto.randomUUID() for reliable uniqueness * - Recording the interview title and required skills * - Setting up timestamps for tracking interview lifecycle * - Setting the initial status to "Created" * */ createInterview(title: InterviewTitle, skills: InterviewSkill[]): string { try { const interviewId = crypto.randomUUID(); const currentTime = Date.now();
this.sql.exec( InterviewDatabaseService.QUERIES.INSERT_INTERVIEW, interviewId, title, JSON.stringify(skills), // Store skills as JSON for flexibility InterviewStatus.Created, currentTime, currentTime, );
return interviewId; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); throw new InterviewError( `Failed to create interview: ${message}`, ErrorCodes.DATABASE_ERROR, ); } }
/** * Fetches all interviews from the database, ordered by creation date. * * This is useful for displaying interview history and letting users * resume previous sessions. We order by descending creation date since * users typically want to see their most recent interviews first. * * Returns an array of InterviewData objects with full interview details * including metadata and message history. */ getAllInterviews(): InterviewData[] { try { const cursor = this.sql.exec( InterviewDatabaseService.QUERIES.GET_ALL_INTERVIEWS, );
return [...cursor].map(this.parseInterviewRecord); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new InterviewError( `Failed to retrieve interviews: ${message}`, ErrorCodes.DATABASE_ERROR, ); } }
// Retrieves an interview and its messages by ID getInterview(interviewId: string): InterviewData | null { try { const cursor = this.sql.exec( InterviewDatabaseService.QUERIES.GET_INTERVIEW, interviewId, );
const record = [...cursor][0]; if (!record) return null;
return this.parseInterviewRecord(record); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); throw new InterviewError( `Failed to retrieve interview: ${message}`, ErrorCodes.DATABASE_ERROR, ); } }
addMessage( interviewId: string, role: Message["role"], content: string, messageId: string, ): Message { try { const timestamp = Date.now();
this.sql.exec( InterviewDatabaseService.QUERIES.INSERT_MESSAGE, messageId, interviewId, role, content, timestamp, );
return { messageId, interviewId, role, content, timestamp, }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); throw new InterviewError( `Failed to add message: ${message}`, ErrorCodes.DATABASE_ERROR, ); } }
/** * Transforms raw database records into structured InterviewData objects. * * This helper does the heavy lifting of: * - Type checking critical fields to catch database corruption early * - Converting stored JSON strings back into proper objects * - Filtering out any null messages that might have snuck in * - Ensuring timestamps are proper numbers * * If any required data is missing or malformed, it throws an error * rather than returning partially valid data that could cause issues * downstream. */ private parseInterviewRecord(record: any): InterviewData { const interviewId = record.interviewId as string; const createdAt = Number(record.createdAt); const updatedAt = Number(record.updatedAt);
if (!interviewId || !createdAt || !updatedAt) { throw new InterviewError( "Invalid interview data in database", ErrorCodes.DATABASE_ERROR, ); }
return { interviewId, title: record.title as InterviewTitle, skills: JSON.parse(record.skills as string) as InterviewSkill[], messages: record.messages ? JSON.parse(record.messages) .filter((m: any) => m !== null) .map((m: any) => ({ messageId: m.messageId, role: m.role, content: m.content, timestamp: m.timestamp, })) : [], status: record.status as InterviewStatus, createdAt, updatedAt, }; }
// Add these SQL queries to the QUERIES object private static readonly QUERIES = { // ... previous queries ...
INSERT_INTERVIEW: ` INSERT INTO ${CONFIG.database.tables.interviews} (interviewId, title, skills, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?) `,
GET_ALL_INTERVIEWS: ` SELECT interviewId, title, skills, createdAt, updatedAt, status FROM ${CONFIG.database.tables.interviews} ORDER BY createdAt DESC `,
INSERT_MESSAGE: ` INSERT INTO ${CONFIG.database.tables.messages} (messageId, interviewId, role, content, timestamp) VALUES (?, ?, ?, ?, ?) `,
GET_INTERVIEW: ` SELECT i.interviewId, i.title, i.skills, i.status, i.createdAt, i.updatedAt, COALESCE( json_group_array( CASE WHEN m.messageId IS NOT NULL THEN json_object( 'messageId', m.messageId, 'role', m.role, 'content', m.content, 'timestamp', m.timestamp ) END ), '[]' ) as messages FROM ${CONFIG.database.tables.interviews} i LEFT JOIN ${CONFIG.database.tables.messages} m ON i.interviewId = m.interviewId WHERE i.interviewId = ? GROUP BY i.interviewId `, };}
Add RPC methods to the Interview
Durable Object to expose database operations through API. Add this code to src/interview.ts
:
import { InterviewData, InterviewTitle, InterviewSkill, Message,} from "./types";
export class Interview extends DurableObject<CloudflareBindings> { // Creates a new interview session createInterview(title: InterviewTitle, skills: InterviewSkill[]): string { return this.db.createInterview(title, skills); }
// Retrieves all interview sessions getAllInterviews(): InterviewData[] { return this.db.getAllInterviews(); }
// Adds a new message to the 'messages' table and broadcasts it to all connected WebSocket clients. addMessage( interviewId: string, role: "user" | "assistant", content: string, messageId: string, ): Message { const newMessage = this.db.addMessage( interviewId, role, content, messageId, ); this.broadcast( JSON.stringify({ ...newMessage, type: "message", }), ); return newMessage; }}
With your Durable Object and database service ready, create REST API endpoints to manage interviews. You will need endpoints to:
- Create new interviews
- Retrieve all interviews for a user
Create a new file for your interview routes at routes/interview.ts
:
import { Hono } from "hono";import { BadRequestError } from "../errors";import { InterviewInput, ApiContext, HonoCtx, InterviewTitle, InterviewSkill,} from "../types";import { requireAuth } from "../middleware/auth";
/** * Gets the Interview Durable Object instance for a given user. * We use the username as a stable identifier to ensure each user * gets their own dedicated DO instance that persists across requests. */const getInterviewDO = (ctx: HonoCtx) => { const username = ctx.get("username"); const id = ctx.env.INTERVIEW.idFromName(username); return ctx.env.INTERVIEW.get(id);};
/** * Validates the interview creation payload. * Makes sure we have all required fields in the correct format: * - title must be present * - skills must be a non-empty array * Throws an error if validation fails. */const validateInterviewInput = (input: InterviewInput) => { if ( !input.title || !input.skills || !Array.isArray(input.skills) || input.skills.length === 0 ) { throw new BadRequestError("Invalid input"); }};
/** * GET /interviews * Retrieves all interviews for the authenticated user. * The interviews are stored and managed by the user's DO instance. */const getAllInterviews = async (ctx: HonoCtx) => { const interviewDO = getInterviewDO(ctx); const interviews = await interviewDO.getAllInterviews(); return ctx.json(interviews);};
/** * POST /interviews * Creates a new interview session with the specified title and skills. * Each interview gets a unique ID that can be used to reference it later. * Returns the newly created interview ID on success. */const createInterview = async (ctx: HonoCtx) => { const body = await ctx.req.json<InterviewInput>(); validateInterviewInput(body);
const interviewDO = getInterviewDO(ctx); const interviewId = await interviewDO.createInterview( body.title as InterviewTitle, body.skills as InterviewSkill[], );
return ctx.json({ success: true, interviewId });};
/** * Sets up all interview-related routes. * Currently supports: * - GET / : List all interviews * - POST / : Create a new interview */export const configureInterviewRoutes = () => { const router = new Hono<ApiContext>(); router.use("*", requireAuth); router.get("/", getAllInterviews); router.post("/", createInterview); return router;};
The getInterviewDO
helper function uses the username from our authentication cookie to create a unique Durable Object ID. This ensures each user has their own isolated interview state.
Update your main application file to include the routes and protect them with authentication middleware. Update src/index.ts
:
import { configureAuthRoutes } from "./routes/auth";import { configureInterviewRoutes } from "./routes/interview";import { Hono } from "hono";import { Interview } from "./interview";import { logger } from "hono/logger";import type { ApiContext } from "./types";
const app = new Hono<ApiContext>();const api = new Hono<ApiContext>();
app.use("*", logger());
api.route("/auth", configureAuthRoutes());api.route("/interviews", configureInterviewRoutes());
app.route("/api/v1", api);
export { Interview };export default app;
Now you have two new API endpoints:
POST /api/v1/interviews
: Creates a new interview sessionGET /api/v1/interviews
: Retrieves all interviews for the authenticated user
You can test these endpoints running the following command:
- Create a new interview:
curl -X POST http://localhost:8787/api/v1/interviews \-H "Content-Type: application/json" \-H "Cookie: username=testuser; HttpOnly" \-d '{"title":"Frontend Developer Interview","skills":["JavaScript","React","CSS"]}'
- Get all interviews:
curl http://localhost:8787/api/v1/interviews \-H "Cookie: username=testuser; HttpOnly"
With the basic interview management system in place, you will now implement Durable Objects to handle real-time message processing and maintain WebSocket connections.
Update the Interview
Durable Object to handle WebSocket connections by adding the following code to src/interview.ts
:
export class Interview extends DurableObject<CloudflareBindings> { // Services for database operations and managing WebSocket sessions private readonly db: InterviewDatabaseService; private sessions: Map<WebSocket, { interviewId: string }>;
constructor(state: DurableObjectState, env: CloudflareBindings) { // ... previous code ...
// Keep WebSocket connections alive by automatically responding to pings // This prevents timeouts and connection drops this.ctx.setWebSocketAutoResponse( new WebSocketRequestResponsePair("ping", "pong"), ); }
async fetch(request: Request): Promise<Response> { // Check if this is a WebSocket upgrade request const upgradeHeader = request.headers.get("Upgrade"); if (upgradeHeader?.toLowerCase().includes("websocket")) { return this.handleWebSocketUpgrade(request); }
// If it is not a WebSocket request, we don't handle it return new Response("Not found", { status: 404 }); }
private async handleWebSocketUpgrade(request: Request): Promise<Response> { // Extract the interview ID from the URL - it should be the last segment const url = new URL(request.url); const interviewId = url.pathname.split("/").pop();
if (!interviewId) { return new Response("Missing interviewId parameter", { status: 400 }); }
// Create a new WebSocket connection pair - one for the client, one for the server const pair = new WebSocketPair(); const [client, server] = Object.values(pair);
// Keep track of which interview this WebSocket is connected to // This is important for routing messages to the right interview session this.sessions.set(server, { interviewId });
// Tell the Durable Object to start handling this WebSocket this.ctx.acceptWebSocket(server);
// Send the current interview state to the client right away // This helps initialize their UI with the latest data const interviewData = await this.db.getInterview(interviewId); if (interviewData) { server.send( JSON.stringify({ type: "interview_details", data: interviewData, }), ); }
// Return the client WebSocket as part of the upgrade response return new Response(null, { status: 101, webSocket: client, }); }
async webSocketClose( ws: WebSocket, code: number, reason: string, wasClean: boolean, ) { // Clean up when a connection closes to prevent memory leaks // This is especially important in long-running Durable Objects console.log( `WebSocket closed: Code ${code}, Reason: ${reason}, Clean: ${wasClean}`, ); }}
Next, update the interview routes to include a WebSocket endpoint. Add the following to routes/interview.ts
:
// ... previous code ...const streamInterviewProcess = async (ctx: HonoCtx) => { const interviewDO = getInterviewDO(ctx); return await interviewDO.fetch(ctx.req.raw);};
export const configureInterviewRoutes = () => { const router = new Hono<ApiContext>(); router.get("/", getAllInterviews); router.post("/", createInterview); // Add WebSocket route router.get("/:interviewId", streamInterviewProcess); return router;};
The WebSocket system provides real-time communication features for interview practice tool:
- Each interview session gets its own dedicated WebSocket connection, allowing seamless communication between the candidate and AI interviewer
- The Durable Object maintains the connection state, ensuring no messages are lost even if the client temporarily disconnects
- To keep connections stable, it automatically responds to ping messages with pongs, preventing timeouts
- Candidates and interviewers receive instant updates as the interview progresses, creating a natural conversational flow
Now that WebSocket connection set up, the next step is to add speech-to-text capabilities using Workers AI. Let's use Cloudflare's Whisper model to transcribe audio in real-time during the interview.
The audio processing pipeline will work like this:
- Client sends audio through the WebSocket connection
- Our Durable Object receives the binary audio data
- We pass the audio to Whisper for transcription
- The transcribed text is saved as a new message
- We immediately send the transcription back to the client
- The client receives a notification that the AI interviewer is generating a response
In this step you will update the Interview Durable Object to handle the following:
- Detect binary audio data sent through WebSocket
- Create a unique message ID for tracking the processing status
- Notify clients that audio processing has begun
- Include error handling for failed audio processing
- Broadcast status updates to all connected clients
First, update Interview Durable Object to handle binary WebSocket messages. Add the following methods to your src/interview.ts
file:
// ... previous code .../** * Handles incoming WebSocket messages, both binary audio data and text messages. * This is the main entry point for all WebSocket communication. */async webSocketMessage(ws: WebSocket, eventData: ArrayBuffer | string): Promise<void> { try { // Handle binary audio data from the client's microphone if (eventData instanceof ArrayBuffer) { await this.handleBinaryAudio(ws, eventData); return; } // Text messages will be handled by other methods } catch (error) { this.handleWebSocketError(ws, error); }}
/** * Processes binary audio data received from the client. * Converts audio to text using Whisper and broadcasts processing status. */private async handleBinaryAudio(ws: WebSocket, audioData: ArrayBuffer): Promise<void> { try { const uint8Array = new Uint8Array(audioData);
// Retrieve the associated interview session const session = this.sessions.get(ws); if (!session?.interviewId) { throw new Error("No interview session found"); }
// Generate unique ID to track this message through the system const messageId = crypto.randomUUID();
// Let the client know we're processing their audio this.broadcast( JSON.stringify({ type: "message", status: "processing", role: "user", messageId, interviewId: session.interviewId, }), );
// TODO: Implement Whisper transcription in next section // For now, just log the received audio data size console.log(`Received audio data of length: ${uint8Array.length}`); } catch (error) { console.error("Audio processing failed:", error); this.handleWebSocketError(ws, error); }}
/** * Handles WebSocket errors by logging them and notifying the client. * Ensures errors are properly communicated back to the user. */private handleWebSocketError(ws: WebSocket, error: unknown): void { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred."; console.error("WebSocket error:", errorMessage);
if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "error", message: errorMessage, }), ); }}
Your handleBinaryAudio
method currently logs when it receives audio data. Next, you'll enhance it to transcribe speech using Workers AI's Whisper model.
Now that audio processing pipeline is set up, you will now integrate Workers AI's Whisper model for speech-to-text transcription.
Configure the Worker AI binding in your Wrangler file by adding:
# ... previous configuration ...[ai]binding = "AI"
Next, generate TypeScript types for our AI binding. Run the following command:
npm run cf-typegen
You will need a new service class for AI operations. Create a new file called services/AIService.ts
:
import { InterviewError, ErrorCodes } from "../errors";
export class AIService { constructor(private readonly AI: Ai) {}
async transcribeAudio(audioData: Uint8Array): Promise<string> { try { // Call the Whisper model to transcribe the audio const response = await this.AI.run("@cf/openai/whisper-tiny-en", { audio: Array.from(audioData), });
if (!response?.text) { throw new Error("Failed to transcribe audio content."); }
return response.text; } catch (error) { throw new InterviewError( "Failed to transcribe audio content", ErrorCodes.TRANSCRIPTION_FAILED, ); } }}
You will need to update the Interview
Durable Object to use this new AI service. To do this, update the handleBinaryAudio method in src/interview.ts
:
import { AIService } from "./services/AIService";
export class Interview extends DurableObject<CloudflareBindings> {private readonly aiService: AIService;
constructor(state: DurableObjectState, env: Env) { // ... previous code ...
// Initialize the AI service with the Workers AI binding this.aiService = new AIService(this.env.AI);}
private async handleBinaryAudio(ws: WebSocket, audioData: ArrayBuffer): Promise<void> { try { const uint8Array = new Uint8Array(audioData); const session = this.sessions.get(ws);
if (!session?.interviewId) { throw new Error("No interview session found"); }
// Create a message ID for tracking const messageId = crypto.randomUUID();
// Send processing state to client this.broadcast( JSON.stringify({ type: "message", status: "processing", role: "user", messageId, interviewId: session.interviewId, }), );
// NEW: Use AI service to transcribe the audio const transcribedText = await this.aiService.transcribeAudio(uint8Array);
// Store the transcribed message await this.addMessage(session.interviewId, "user", transcribedText, messageId);
} catch (error) { console.error("Audio processing failed:", error); this.handleWebSocketError(ws, error); }}
When users speak during the interview, their audio will be automatically transcribed and stored as messages in the interview session. The transcribed text will be immediately available to both the user and the AI interviewer for generating appropriate responses.
Now that you have audio transcription working, let's implement AI interviewer response generation using Workers AI's LLM capabilities. You'll create an interview system that:
- Maintains context of the conversation
- Provides relevant follow-up questions
- Gives constructive feedback
- Stays in character as a professional interviewer
First, update the AIService
class to handle LLM interactions. You will need to add methods for:
- Processing interview context
- Generating appropriate responses
- Handling conversation flow
Update the services/AIService.ts
class to include LLM functionality:
import { InterviewData, Message } from "../types";
export class AIService {
async processLLMResponse(interview: InterviewData): Promise<string> { const messages = this.prepareLLMMessages(interview);
try { const { response } = await this.AI.run("@cf/meta/llama-2-7b-chat-int8", { messages, });
if (!response) { throw new Error("Failed to generate a response from the LLM model."); }
return response; } catch (error) { throw new InterviewError("Failed to generate a response from the LLM model.", ErrorCodes.LLM_FAILED); }}
private prepareLLMMessages(interview: InterviewData) { const messageHistory = interview.messages.map((msg: Message) => ({ role: msg.role, content: msg.content, }));
return [ { role: "system", content: this.createSystemPrompt(interview), }, ...messageHistory, ];}
Prompt engineering is crucial for getting high-quality responses from the LLM. Next, you will create a system prompt that:
- Sets the context for the interview
- Defines the interviewer's role and behavior
- Specifies the technical focus areas
- Guides the conversation flow
Add the following method to your services/AIService.ts
class:
private createSystemPrompt(interview: InterviewData): string { const basePrompt = "You are conducting a technical interview."; const rolePrompt = `The position is for ${interview.title}.`; const skillsPrompt = `Focus on topics related to: ${interview.skills.join(", ")}.`; const instructionsPrompt = "Ask relevant technical questions and provide constructive feedback.";
return `${basePrompt} ${rolePrompt} ${skillsPrompt} ${instructionsPrompt}`;}
Finally, integrate the LLM response generation into the interview flow. Update the handleBinaryAudio
method in the src/interview.ts
Durable Object to:
- Process transcribed user responses
- Generate appropriate AI interviewer responses
- Maintain conversation context
Update the handleBinaryAudio
method in src/interview.ts
:
private async handleBinaryAudio(ws: WebSocket, audioData: ArrayBuffer): Promise<void> { try { // Convert raw audio buffer to uint8 array for processing const uint8Array = new Uint8Array(audioData); const session = this.sessions.get(ws);
if (!session?.interviewId) { throw new Error("No interview session found"); }
// Generate a unique ID to track this message through the system const messageId = crypto.randomUUID();
// Let the client know we're processing their audio // This helps provide immediate feedback while transcription runs this.broadcast( JSON.stringify({ type: "message", status: "processing", role: "user", messageId, interviewId: session.interviewId, }), );
// Convert the audio to text using our AI transcription service // This typically takes 1-2 seconds for normal speech const transcribedText = await this.aiService.transcribeAudio(uint8Array);
// Save the user's message to our database so we maintain chat history await this.addMessage(session.interviewId, "user", transcribedText, messageId);
// Look up the full interview context - we need this to generate a good response const interview = await this.db.getInterview(session.interviewId); if (!interview) { throw new Error(`Interview not found: ${session.interviewId}`); }
// Now it's the AI's turn to respond // First generate an ID for the assistant's message const assistantMessageId = crypto.randomUUID();
// Let the client know we're working on the AI response this.broadcast( JSON.stringify({ type: "message", status: "processing", role: "assistant", messageId: assistantMessageId, interviewId: session.interviewId, }), );
// Generate the AI interviewer's response based on the conversation history const llmResponse = await this.aiService.processLLMResponse(interview); await this.addMessage(session.interviewId, "assistant", llmResponse, assistantMessageId); } catch (error) { // Something went wrong processing the audio or generating a response // Log it and let the client know there was an error console.error("Audio processing failed:", error); this.handleWebSocketError(ws, error); }}
You have successfully built an AI-powered interview practice tool using Cloudflare's Workers AI. In summary, you have:
- Created a real-time WebSocket communication system using Durable Objects
- Implemented speech-to-text processing with Workers AI Whisper model
- Built an intelligent interview system using Workers AI LLM capabilities
- Designed a persistent storage system with SQLite in Durable Objects
The complete source code for this tutorial is available on GitHub: ai-interview-practice-tool ↗
- @2025 Cloudflare Ubitools
- Cf Repo