Skip to content

Commit 204c428

Browse files
许君山许君山
authored andcommitted
feat: improve AI UX with priority explanation, progressive inline validation and animated progress bar
1 parent ec9a5e3 commit 204c428

2 files changed

Lines changed: 102 additions & 13 deletions

File tree

backend/server.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,16 @@ ${JSON.stringify(conjugationResult, null, 2)}
160160
161161
请你严格按照以下结构执行任务:
162162
163-
第一步:逐个核对上述 JSON 中的变形结果。必须且只能以一个 JSON 代码块开始你的回答,不要有任何前置文本。格式要求:
163+
第一步:用中文简明扼要地解释该动词的含义,并提供2个实用的日常例句(必须包含日文原文、平假名注音和精准的中文翻译)。支持使用 Markdown 格式加粗、高亮。
164+
165+
第二步:在例句之后,逐个核对上述 JSON 中的变形结果。必须且只能以一个 JSON 代码块作为你回答的结尾。格式要求:
164166
1. 请只核对这 9 种变形:negative, polite, teForm, taForm, potential, passive, causative, imperative, volitional。
165167
2. 必须使用给定的英文 key。
166168
3. 如果结果完全正确,请将 isCorrect 设置为 true,correction 必须为空字符串 ""。
167169
4. 只有当你 100% 确定系统生成的结果错误时,才将 isCorrect 设置为 false,并在 correction 中给出正确的日文。
168170
5. 不要因为送气音或汉字/假名的写法不同就认为是错的。
169171
170-
返回的 JSON 必须严格遵循如下结构(此为全对的示例):
172+
结尾的 JSON 必须严格遵循如下结构(此为全对的示例):
171173
\`\`\`json
172174
{
173175
"negative": { "isCorrect": true, "correction": "" },
@@ -180,9 +182,7 @@ ${JSON.stringify(conjugationResult, null, 2)}
180182
"imperative": { "isCorrect": true, "correction": "" },
181183
"volitional": { "isCorrect": true, "correction": "" }
182184
}
183-
\`\`\`
184-
185-
第二步:在 JSON 代码块之后,用中文简明扼要地解释该动词的含义,并提供2个实用的日常例句(必须包含日文原文、平假名注音和精准的中文翻译)。支持使用 Markdown 格式加粗、高亮。`;
185+
\`\`\``;
186186

187187
res.setHeader('Content-Type', 'text/event-stream');
188188
res.setHeader('Cache-Control', 'no-cache');

frontend/src/App.vue

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
</div>
6464
</div>
6565

66+
<div v-if="loadingAi || (aiProgress > 0 && aiProgress < 100)" class="ai-progress-container">
67+
<div class="ai-progress-bar" :style="{ width: aiProgress + '%' }"></div>
68+
</div>
69+
6670
<div v-if="loadingAi && !aiRawExplanation" class="ai-loading">
6771
<div class="spinner"></div>
6872
<p>Ollama 正在思考中,请稍候...</p>
@@ -194,7 +198,7 @@
194198
</template>
195199

196200
<script setup>
197-
import { ref, watch, onMounted, computed } from 'vue';
201+
import { ref, watch, onMounted, computed, onUnmounted } from 'vue';
198202
import axios from 'axios';
199203
import { marked } from 'marked';
200204
@@ -207,6 +211,8 @@ const aiRawExplanation = ref('');
207211
const verificationStatus = ref({});
208212
const loading = ref(false);
209213
const loadingAi = ref(false);
214+
const aiProgress = ref(0);
215+
let aiProgressInterval = null;
210216
const error = ref('');
211217
const aiError = ref('');
212218
const showSuggestions = ref(false);
@@ -288,6 +294,29 @@ const hideSuggestionsWithDelay = () => {
288294
}, 200);
289295
};
290296
297+
const startProgress = () => {
298+
aiProgress.value = 0;
299+
if (aiProgressInterval) clearInterval(aiProgressInterval);
300+
// 假设通常响应在 6-10 秒左右
301+
aiProgressInterval = setInterval(() => {
302+
if (aiProgress.value < 90) {
303+
aiProgress.value += (90 - aiProgress.value) * 0.1;
304+
}
305+
}, 500);
306+
};
307+
308+
const completeProgress = () => {
309+
if (aiProgressInterval) clearInterval(aiProgressInterval);
310+
aiProgress.value = 100;
311+
setTimeout(() => {
312+
aiProgress.value = 0;
313+
}, 500);
314+
};
315+
316+
onUnmounted(() => {
317+
if (aiProgressInterval) clearInterval(aiProgressInterval);
318+
});
319+
291320
const conjugate = async () => {
292321
error.value = '';
293322
result.value = null;
@@ -313,6 +342,11 @@ const conjugate = async () => {
313342
});
314343
result.value = response.data;
315344
345+
// 如果返回了合法的 dictionaryForm,将其同步回输入框(包含汉字转换)
346+
if (result.value.dictionaryForm) {
347+
form.value.verb = result.value.dictionaryForm;
348+
}
349+
316350
// 自动触发 AI 解析
317351
fetchAiExplanation();
318352
} catch (err) {
@@ -338,6 +372,7 @@ const fetchAiExplanation = async () => {
338372
verificationStatus.value = {};
339373
340374
try {
375+
startProgress();
341376
const response = await fetch('/api/ai-explain', {
342377
method: 'POST',
343378
headers: {
@@ -375,29 +410,57 @@ const fetchAiExplanation = async () => {
375410
if (eventStr.startsWith('data: ')) {
376411
const dataStr = eventStr.slice(6);
377412
if (dataStr === '[DONE]') {
413+
completeProgress();
378414
break;
379415
}
380416
try {
381417
const data = JSON.parse(dataStr);
382418
if (data.error) {
383419
aiError.value = data.error;
420+
completeProgress();
384421
} else if (data.content) {
385422
fullAiText += data.content;
386423
387424
// 尝试匹配 AI 返回的 JSON 代码块
388-
const jsonMatch = fullAiText.match(/```(?:json)?\s*\n([\s\S]*?)\n```/i);
425+
const jsonMatch = fullAiText.match(/```(?:json)?\s*\n([\s\S]*?)(?:\n```|$)/i);
389426
if (jsonMatch) {
390427
try {
391-
verificationStatus.value = JSON.parse(jsonMatch[1]);
392-
// JSON 之后的内容作为解释显示
393-
aiRawExplanation.value = fullAiText.substring(jsonMatch.index + jsonMatch[0].length).trim();
428+
// 如果代码块完整,直接解析
429+
const parsed = JSON.parse(jsonMatch[1]);
430+
verificationStatus.value = parsed;
431+
aiRawExplanation.value = fullAiText.substring(0, jsonMatch.index).trim();
394432
} catch (e) {
395-
// JSON 解析失败说明还在流式输出 JSON,忽略
433+
// JSON 解析失败说明还在流式输出 JSON,尝试用部分匹配提前点亮 ✅
434+
const partialJson = jsonMatch[1];
435+
const items = partialJson.split(/},?/);
436+
for (let item of items) {
437+
const keyMatch = item.match(/"([a-zA-Z]+)"\s*:\s*\{/);
438+
const isCorrectMatch = item.match(/"isCorrect"\s*:\s*(true|false)/);
439+
if (keyMatch && isCorrectMatch) {
440+
const key = keyMatch[1];
441+
const isCorrect = isCorrectMatch[1] === 'true';
442+
// 简单提取 correction(如果不完整可能提取不到,但主要是为了尽早显示正确状态)
443+
const correctionMatch = item.match(/"correction"\s*:\s*"([^"]*)"/);
444+
const correction = correctionMatch ? correctionMatch[1] : "";
445+
446+
if (!verificationStatus.value[key]) {
447+
verificationStatus.value = {
448+
...verificationStatus.value,
449+
[key]: { isCorrect, correction }
450+
};
451+
}
452+
}
453+
}
454+
aiRawExplanation.value = fullAiText.substring(0, jsonMatch.index).trim();
396455
}
397456
} else {
398-
// 如果还没有完整的 JSON 块,且不是以 JSON 块开头,直接显示
399-
if (!fullAiText.trim().startsWith('```')) {
457+
// 如果还没有遇到 JSON 块开头,直接显示当前所有内容
458+
if (!fullAiText.includes('```')) {
400459
aiRawExplanation.value = fullAiText;
460+
} else {
461+
// 如果遇到了块开头,但还没闭合,只显示开头前面的部分
462+
const blockStart = fullAiText.indexOf('```');
463+
aiRawExplanation.value = fullAiText.substring(0, blockStart).trim();
401464
}
402465
}
403466
}
@@ -409,6 +472,7 @@ const fetchAiExplanation = async () => {
409472
}
410473
} catch (err) {
411474
aiError.value = err.message || 'AI 解析请求失败';
475+
completeProgress();
412476
} finally {
413477
loadingAi.value = false;
414478
}
@@ -683,6 +747,22 @@ const fetchAiExplanation = async () => {
683747
}
684748
685749
/* AI 解释区域样式 */
750+
.ai-progress-container {
751+
width: 100%;
752+
height: 4px;
753+
background-color: #edf2f7;
754+
border-radius: 2px;
755+
margin-bottom: 15px;
756+
overflow: hidden;
757+
}
758+
759+
.ai-progress-bar {
760+
height: 100%;
761+
background-color: #667eea;
762+
transition: width 0.3s ease;
763+
border-radius: 2px;
764+
}
765+
686766
.ai-loading {
687767
display: flex;
688768
align-items: center;
@@ -752,8 +832,15 @@ const fetchAiExplanation = async () => {
752832
color: #999;
753833
}
754834
835+
@keyframes popIn {
836+
0% { transform: scale(0.5); opacity: 0; }
837+
100% { transform: scale(1); opacity: 1; }
838+
}
839+
755840
.success-check {
756841
color: #38a169;
842+
animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
843+
display: inline-block;
757844
}
758845
759846
.error-correction {
@@ -763,6 +850,8 @@ const fetchAiExplanation = async () => {
763850
padding: 2px 6px;
764851
border-radius: 4px;
765852
border: 1px solid #fed7d7;
853+
animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
854+
display: inline-block;
766855
}
767856
768857
.model-select {

0 commit comments

Comments
 (0)