page-interview.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import { Component, OnInit, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import * as echarts from 'echarts';
  4. import { AiApiService } from '../../../../lib/ai-api.service';
  5. @Component({
  6. selector: 'app-page-interview',
  7. standalone: true,
  8. imports: [CommonModule],
  9. templateUrl: './page-interview.html',
  10. styleUrl: './page-interview.scss'
  11. })
  12. export class PageInterview implements OnInit, AfterViewInit {
  13. // 计时相关
  14. remainingTime = '10:00';
  15. progress = 0;
  16. // 面试状态控制
  17. interviewEnded = false;
  18. isAnalyzing = false;
  19. showSubmitButton = false;
  20. showCancelButton = false;
  21. isFollowUpQuestion = false;
  22. showQuestionCard = true;
  23. showRadarChart = false;
  24. // 语音相关
  25. isListening = false;
  26. isSpeaking = false;
  27. userAnswer = '';
  28. recognition: any;
  29. interimTranscript = '';
  30. private voiceEndTimer: any;
  31. private readonly VOICE_END_DELAY = 1500;
  32. private speechBuffer: string[] = [];
  33. private lastFinalResultIndex = 0;
  34. // 思考时间控制
  35. private silenceTimer: any;
  36. silenceDuration = 0;
  37. readonly MAX_SILENCE = 5;
  38. // 自我介绍相关
  39. private isSelfIntroduction = false;
  40. private minSelfIntroDuration = 5000; // 至少5秒自我介绍时间
  41. private selfIntroTimer: any;
  42. private minSelfIntroWords = 30; // 至少30字的自我介绍
  43. // 问题列表
  44. mainQuestions = [
  45. "欢迎参加本次AI面试,我是您的面试官AI助手。",
  46. "首先,请简单介绍一下您自己。",
  47. "您学会那些,专业技能",
  48. "您在过去工作中遇到的最大技术挑战是什么?您是如何解决的?",
  49. "请描述一个您与团队意见不合时,您是如何处理的案例。"
  50. ];
  51. currentQuestionIndex = 0;
  52. currentQuestion = this.mainQuestions[0];
  53. // 对话记录
  54. messages: {sender: 'ai' | 'user', text: string}[] = [];
  55. // 头像状态
  56. avatarImage = "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f916.svg";
  57. expressionText = "等待开始...";
  58. // 评估指标
  59. metrics = {
  60. expressiveness: 0,
  61. professionalism: 0,
  62. relevance: 0
  63. };
  64. @ViewChild('dialogContainer') dialogContainer!: ElementRef;
  65. private radarChart: any;
  66. constructor(private aiService: AiApiService) {
  67. this.initSpeechRecognition();
  68. }
  69. ngOnInit(): void {
  70. this.initConversation();
  71. this.initProgressTimer();
  72. }
  73. ngAfterViewInit(): void {
  74. this.initRadarChart();
  75. }
  76. private initSpeechRecognition() {
  77. const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
  78. if (SpeechRecognition) {
  79. this.recognition = new SpeechRecognition();
  80. this.recognition.lang = 'zh-CN';
  81. this.recognition.interimResults = true;
  82. this.recognition.continuous = true;
  83. this.recognition.onsoundstart = () => {
  84. this.updateAvatarState('listening');
  85. this.resetSilenceTimer();
  86. };
  87. this.recognition.onsoundend = () => {
  88. this.stopSilenceTimer();
  89. };
  90. this.recognition.onresult = (event: any) => {
  91. this.resetSilenceTimer();
  92. let finalTranscript = '';
  93. let interimTranscript = '';
  94. let hasNewFinalResult = false;
  95. for (let i = this.lastFinalResultIndex; i < event.results.length; i++) {
  96. const transcript = event.results[i][0].transcript;
  97. if (event.results[i].isFinal) {
  98. this.speechBuffer.push(transcript);
  99. finalTranscript += transcript;
  100. hasNewFinalResult = true;
  101. this.lastFinalResultIndex = i + 1;
  102. } else {
  103. interimTranscript = transcript;
  104. }
  105. }
  106. if (this.speechBuffer.length > 0) {
  107. this.userAnswer = this.speechBuffer.join(' ');
  108. }
  109. if (hasNewFinalResult) {
  110. this.resetVoiceEndTimer();
  111. }
  112. this.interimTranscript = interimTranscript;
  113. };
  114. this.recognition.onerror = (event: any) => {
  115. console.error('语音识别错误:', event.error);
  116. this.updateAvatarState('listening');
  117. this.stopVoiceEndTimer();
  118. if (this.isListening && event.error !== 'no-speech') {
  119. setTimeout(() => {
  120. if (this.isListening) {
  121. this.recognition.start();
  122. }
  123. }, 500);
  124. }
  125. };
  126. this.recognition.onend = () => {
  127. if (this.isListening) {
  128. this.recognition.start();
  129. }
  130. };
  131. }
  132. }
  133. private resetVoiceEndTimer() {
  134. this.stopVoiceEndTimer();
  135. this.voiceEndTimer = setTimeout(() => {
  136. if (this.userAnswer.trim().length > 0) {
  137. this.showSubmitButton = true;
  138. this.showCancelButton = true;
  139. }
  140. }, this.VOICE_END_DELAY);
  141. }
  142. private stopVoiceEndTimer() {
  143. if (this.voiceEndTimer) {
  144. clearTimeout(this.voiceEndTimer);
  145. this.voiceEndTimer = null;
  146. }
  147. }
  148. private startSelfIntroTimer() {
  149. this.stopSelfIntroTimer();
  150. this.selfIntroTimer = setTimeout(() => {
  151. if (this.isListening && this.userAnswer.trim().length > 0) {
  152. this.stopVoiceInput();
  153. this.showSubmitButton = true;
  154. this.showCancelButton = true;
  155. }
  156. }, this.minSelfIntroDuration);
  157. }
  158. private stopSelfIntroTimer() {
  159. if (this.selfIntroTimer) {
  160. clearTimeout(this.selfIntroTimer);
  161. this.selfIntroTimer = null;
  162. }
  163. }
  164. private resetSilenceTimer() {
  165. this.stopSilenceTimer();
  166. this.silenceDuration = 0;
  167. this.silenceTimer = setInterval(() => {
  168. this.silenceDuration++;
  169. }, 1000);
  170. }
  171. private stopSilenceTimer() {
  172. if (this.silenceTimer) {
  173. clearInterval(this.silenceTimer);
  174. this.silenceTimer = null;
  175. }
  176. }
  177. private initProgressTimer(): void {
  178. const totalSeconds = 10 * 60;
  179. let elapsedSeconds = 0;
  180. const timer = setInterval(() => {
  181. if (this.interviewEnded) {
  182. clearInterval(timer);
  183. return;
  184. }
  185. elapsedSeconds++;
  186. this.progress = Math.min((elapsedSeconds / totalSeconds) * 100, 100);
  187. const remainingSeconds = totalSeconds - elapsedSeconds;
  188. const minutes = Math.floor(remainingSeconds / 60);
  189. const seconds = remainingSeconds % 60;
  190. this.remainingTime = `${minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
  191. if (elapsedSeconds >= totalSeconds) {
  192. clearInterval(timer);
  193. this.addAIMessage("时间到,本次面试结束");
  194. this.interviewEnded = true;
  195. }
  196. }, 1000);
  197. }
  198. initConversation(): void {
  199. this.addAIMessage(this.mainQuestions[0]);
  200. setTimeout(() => {
  201. this.askQuestion(1);
  202. }, 1500);
  203. }
  204. askQuestion(index: number): void {
  205. if (index >= this.mainQuestions.length || this.interviewEnded) {
  206. this.endInterview();
  207. return;
  208. }
  209. this.currentQuestionIndex = index;
  210. this.isFollowUpQuestion = false;
  211. this.currentQuestion = this.mainQuestions[index];
  212. this.isSelfIntroduction = index === 1; // 第二个问题是自我介绍
  213. this.speechBuffer = [];
  214. this.lastFinalResultIndex = 0;
  215. this.userAnswer = '';
  216. this.interimTranscript = '';
  217. this.addAIMessage(this.currentQuestion);
  218. this.updateAvatarState('speaking');
  219. this.speakText(this.currentQuestion);
  220. if (this.isSelfIntroduction) {
  221. this.startSelfIntroTimer();
  222. }
  223. }
  224. async askFollowUpQuestion(followUpText: string): Promise<void> {
  225. if (this.interviewEnded) return;
  226. this.isFollowUpQuestion = true;
  227. this.currentQuestion = followUpText;
  228. this.isSelfIntroduction = false;
  229. this.speechBuffer = [];
  230. this.lastFinalResultIndex = 0;
  231. this.userAnswer = '';
  232. this.interimTranscript = '';
  233. this.addAIMessage("关于您刚才的回答,我有一个跟进问题...");
  234. setTimeout(() => {
  235. this.addAIMessage(this.currentQuestion);
  236. this.updateAvatarState('speaking');
  237. }, 1000);
  238. }
  239. endInterview(): void {
  240. this.interviewEnded = true;
  241. this.addAIMessage("感谢您参与本次面试,祝您有美好的一天!");
  242. this.updateAvatarState('default');
  243. }
  244. restartInterview(): void {
  245. this.interviewEnded = false;
  246. this.currentQuestionIndex = 0;
  247. this.currentQuestion = this.mainQuestions[0];
  248. this.messages = [];
  249. this.metrics = { expressiveness: 0, professionalism: 0, relevance: 0 };
  250. this.progress = 0;
  251. this.speechBuffer = [];
  252. this.lastFinalResultIndex = 0;
  253. this.isSelfIntroduction = false;
  254. this.stopSelfIntroTimer();
  255. this.initConversation();
  256. }
  257. addAIMessage(text: string): void {
  258. this.messages.push({
  259. sender: 'ai',
  260. text: text
  261. });
  262. this.scrollToBottom();
  263. }
  264. addUserMessage(text: string): void {
  265. this.messages.push({
  266. sender: 'user',
  267. text: text
  268. });
  269. this.scrollToBottom();
  270. }
  271. private scrollToBottom(): void {
  272. setTimeout(() => {
  273. this.dialogContainer.nativeElement.scrollTop = this.dialogContainer.nativeElement.scrollHeight;
  274. }, 0);
  275. }
  276. speakText(text: string): void {
  277. if (this.isSpeaking || !text) return;
  278. this.isSpeaking = true;
  279. this.updateAvatarState('speaking');
  280. const utterance = new SpeechSynthesisUtterance(text);
  281. utterance.lang = 'zh-CN';
  282. utterance.rate = 0.9;
  283. utterance.pitch = 1;
  284. utterance.onend = () => {
  285. this.isSpeaking = false;
  286. if (!this.interviewEnded) {
  287. this.updateAvatarState('listening');
  288. }
  289. };
  290. speechSynthesis.speak(utterance);
  291. }
  292. startVoiceInput(): void {
  293. if (!this.recognition || this.isSpeaking || this.isAnalyzing) return;
  294. this.isListening = true;
  295. this.userAnswer = '';
  296. this.interimTranscript = '';
  297. this.showSubmitButton = false;
  298. this.showCancelButton = false;
  299. this.speechBuffer = [];
  300. this.lastFinalResultIndex = 0;
  301. // 调整自我介绍问题的识别参数
  302. if (this.isSelfIntroduction) {
  303. this.recognition.continuous = true;
  304. this.recognition.interimResults = true;
  305. this.recognition.maxAlternatives = 1;
  306. }
  307. try {
  308. this.recognition.start();
  309. } catch (e) {
  310. console.error('语音识别启动失败:', e);
  311. setTimeout(() => this.startVoiceInput(), 500);
  312. }
  313. }
  314. stopVoiceInput(): void {
  315. if (!this.isListening) return;
  316. this.isListening = false;
  317. this.stopSilenceTimer();
  318. this.stopVoiceEndTimer();
  319. this.stopSelfIntroTimer();
  320. try {
  321. this.recognition.stop();
  322. } catch (e) {
  323. console.error('语音识别停止失败:', e);
  324. }
  325. setTimeout(() => {
  326. if (this.userAnswer.trim().length > 0) {
  327. this.showSubmitButton = true;
  328. this.showCancelButton = true;
  329. }
  330. }, 500);
  331. this.updateAvatarState('waiting');
  332. }
  333. private getWordCount(text: string): number {
  334. // 简单的中文字数统计
  335. return text.replace(/[^\u4e00-\u9fa5]/g, '').length;
  336. }
  337. private validateSelfIntroduction(): boolean {
  338. if (!this.isSelfIntroduction) return true;
  339. const wordCount = this.getWordCount(this.userAnswer);
  340. if (wordCount < this.minSelfIntroWords) {
  341. this.addAIMessage(`您的自我介绍需要至少${this.minSelfIntroWords}字,请再详细介绍一下。`);
  342. return false;
  343. }
  344. return true;
  345. }
  346. async submitAnswer(): Promise<void> {
  347. if (!this.userAnswer || this.isAnalyzing) return;
  348. // 验证自我介绍是否符合要求
  349. if (!this.validateSelfIntroduction()) {
  350. // 不符合要求,重置状态让用户重新回答
  351. this.userAnswer = '';
  352. this.speechBuffer = [];
  353. this.lastFinalResultIndex = 0;
  354. this.showSubmitButton = false;
  355. this.showCancelButton = false;
  356. this.startVoiceInput();
  357. return;
  358. }
  359. this.showSubmitButton = false;
  360. this.showCancelButton = false;
  361. this.addUserMessage(this.userAnswer);
  362. this.isAnalyzing = true;
  363. this.updateAvatarState('analyzing');
  364. try {
  365. const evaluation = await this.aiService.evaluateInterviewAnswer(
  366. this.currentQuestion,
  367. this.userAnswer
  368. );
  369. this.metrics = {
  370. expressiveness: evaluation.metrics.expressiveness,
  371. professionalism: evaluation.metrics.professionalism,
  372. relevance: evaluation.metrics.relevance
  373. };
  374. this.addAIMessage(evaluation.feedback);
  375. if (evaluation.metrics.relevance < 30) {
  376. this.addAIMessage("您的回答与问题相关性较低,本次面试结束。");
  377. this.interviewEnded = true;
  378. return;
  379. }
  380. if (!this.isFollowUpQuestion && this.currentQuestionIndex !== 1) {
  381. setTimeout(() => {
  382. this.askFollowUpQuestion(evaluation.followUpQuestion);
  383. }, 1500);
  384. } else {
  385. setTimeout(() => {
  386. this.askQuestion(this.currentQuestionIndex + 1);
  387. }, 1500);
  388. }
  389. } catch (error) {
  390. console.error('评估失败:', error);
  391. this.addAIMessage("分析完成,让我们继续下一个问题");
  392. this.askQuestion(this.currentQuestionIndex + 1);
  393. } finally {
  394. this.isAnalyzing = false;
  395. this.userAnswer = '';
  396. this.speechBuffer = [];
  397. this.lastFinalResultIndex = 0;
  398. this.isSelfIntroduction = false;
  399. }
  400. }
  401. cancelAnswer(): void {
  402. this.showSubmitButton = false;
  403. this.showCancelButton = false;
  404. this.userAnswer = '';
  405. this.speechBuffer = [];
  406. this.lastFinalResultIndex = 0;
  407. this.isSelfIntroduction = false;
  408. this.updateAvatarState('listening');
  409. }
  410. playQuestion(): void {
  411. if (!this.isListening && !this.isSpeaking) {
  412. this.speakText(this.currentQuestion);
  413. }
  414. }
  415. updateAvatarState(state: 'speaking' | 'listening' | 'waiting' | 'analyzing' | 'default'): void {
  416. const emojiMap = {
  417. 'speaking': '1f5e3',
  418. 'listening': '1f3a4',
  419. 'waiting': '1f914',
  420. 'analyzing': '1f9d0',
  421. 'default': '1f916'
  422. };
  423. const stateTexts = {
  424. 'speaking': "正在提问...",
  425. 'listening': "正在聆听...",
  426. 'waiting': "等待确认...",
  427. 'analyzing': "分析回答中...",
  428. 'default': "AI面试官"
  429. };
  430. this.avatarImage = `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${emojiMap[state]}.svg`;
  431. this.expressionText = stateTexts[state];
  432. }
  433. toggleRadarChart(): void {
  434. this.showRadarChart = !this.showRadarChart;
  435. if (this.showRadarChart) {
  436. setTimeout(() => {
  437. this.initRadarChart();
  438. }, 0);
  439. }
  440. }
  441. private initRadarChart(): void {
  442. if (!this.showRadarChart || this.interviewEnded) return;
  443. const chartDom = document.getElementById('radarChart');
  444. if (!chartDom) return;
  445. if (this.radarChart) {
  446. this.radarChart.dispose();
  447. }
  448. this.radarChart = echarts.init(chartDom);
  449. const option = {
  450. backgroundColor: 'transparent',
  451. tooltip: {},
  452. radar: {
  453. shape: 'circle',
  454. indicator: [
  455. { name: '表达能力', max: 100 },
  456. { name: '专业深度', max: 100 },
  457. { name: '岗位匹配', max: 100 },
  458. { name: '应变能力', max: 100 },
  459. { name: '沟通技巧', max: 100 },
  460. { name: '知识广度', max: 100 }
  461. ],
  462. radius: '65%',
  463. axisName: { color: '#4A5568' },
  464. splitArea: { areaStyle: { color: ['rgba(42, 92, 170, 0.1)'] } },
  465. axisLine: { lineStyle: { color: 'rgba(42, 92, 170, 0.3)' } },
  466. splitLine: { lineStyle: { color: 'rgba(42, 92, 170, 0.3)' } }
  467. },
  468. series: [{
  469. type: 'radar',
  470. data: [
  471. {
  472. value: [
  473. this.metrics.expressiveness,
  474. this.metrics.professionalism,
  475. this.metrics.relevance,
  476. (this.metrics.expressiveness + this.metrics.professionalism) / 2,
  477. this.metrics.expressiveness,
  478. this.metrics.professionalism
  479. ],
  480. name: '当前表现',
  481. areaStyle: { color: 'rgba(42, 92, 170, 0.4)' },
  482. lineStyle: { width: 2, color: 'rgba(42, 92, 170, 0.8)' },
  483. itemStyle: { color: '#2A5CAA' }
  484. },
  485. {
  486. value: [70, 80, 85, 75, 65, 70],
  487. name: '岗位要求',
  488. areaStyle: { color: 'rgba(255, 107, 53, 0.2)' },
  489. lineStyle: { width: 2, color: 'rgba(255, 107, 53, 0.8)' },
  490. itemStyle: { color: '#FF6B35' }
  491. }
  492. ]
  493. }]
  494. };
  495. this.radarChart.setOption(option);
  496. window.addEventListener('resize', () => {
  497. this.radarChart?.resize();
  498. });
  499. }
  500. }