修复: aichat AR-5/7/9(stop 兜底 + clean UI 真删 + friendlyError i18n)
- AR-5 stopChat 本地先复位 streaming + clearStreamWatchdog(防审批态看门狗已 clear + AiCompleted 竞态丢失致 streaming 永真卡死) - AR-7 clean UI + 后端真删:crud.rs 加 AiConversationRepo::clear_messages(置空 messages JSON + 清零 token,保留对话壳)+ commands.rs ai_chat_clear 调之真删 DB + AiChat 垃桶按钮二次确认。**主代理补完 agent 半成品**:agent impl 声称改 commands.rs 实际 diff 零改动(self_boundary_check 造假),审查 semantic_check 正确抓到此 gap - AR-9 friendlyError 全走 i18n.global.t + zh/en 双语补 4 key(TS2589 用 as any 规避 vue-i18n 深度泛型) - cargo check / vue-tsc 0 error
This commit is contained in:
@@ -1339,6 +1339,28 @@ impl KnowledgeEventsRepo {
|
||||
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||
|
||||
impl AiConversationRepo {
|
||||
/// 清空对话消息内容(保留 conversation 记录本身,只清 messages JSON + 清零 token 计数)
|
||||
///
|
||||
/// "清空对话"语义:对话壳保留(侧栏仍可见,可继续在该对话内聊),仅清空历史消息。
|
||||
/// messages 是 ai_conversations 表内的 JSON 列而非独立行,故"删 messages"= 置空该列。
|
||||
pub async fn clear_messages(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, updated_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 设置归档标记(仅改 archived,不动 updated_at)
|
||||
///
|
||||
/// 区别于 update_field(后者强制 SET updated_at=now,会把归档/取消归档误判为内容更新,
|
||||
|
||||
@@ -214,8 +214,19 @@ pub async fn ai_pending_tool_calls(
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)
|
||||
let active_id = session.active_conversation_id.clone();
|
||||
session.messages.clear();
|
||||
session.pending_approvals.clear();
|
||||
drop(session);
|
||||
// 真删 DB:清空该对话 messages JSON + 清零 token(保留对话壳),刷新不再恢复(AR-7)
|
||||
if let Some(id) = active_id {
|
||||
state
|
||||
.ai_conversations
|
||||
.clear_messages(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
<button class="ai-btn-icon" @click="store.newConversation()" :title="$t('aiChat.newConversation')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
||||
<button class="ai-btn-icon" @click="confirmClearChat" title="清空">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||
</button>
|
||||
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
||||
<template v-if="!props.detached">
|
||||
<button class="ai-btn-icon" @click="store.toggleMaximize()" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
||||
@@ -386,6 +390,13 @@ async function confirmDeleteConversation(id: string) {
|
||||
store.deleteConversation(id)
|
||||
}
|
||||
|
||||
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
|
||||
async function confirmClearChat() {
|
||||
// i18n 不在本任务白名单:文案硬编码,后续补 aiChat.confirmClearChat key
|
||||
if (!await confirmDialog('确定清空当前对话的所有消息?此操作不可恢复。')) return
|
||||
await store.clearChat()
|
||||
}
|
||||
|
||||
// ── 技能 `/` 联想 ──
|
||||
const skillOpen = ref(false)
|
||||
const skillIndex = ref(0)
|
||||
|
||||
@@ -14,11 +14,16 @@ import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import i18n from '../../i18n'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { drainQueue } from './useAiSend'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiToolCallInfo } from '../../api/types'
|
||||
|
||||
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
||||
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
||||
const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any).global)
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
||||
@@ -40,10 +45,10 @@ export function notifyConversationChanged() {
|
||||
|
||||
/** 后端原始错误转用户友好提示 */
|
||||
export function friendlyError(raw: string): string {
|
||||
if (/404|not\s*found/i.test(raw)) return '调用失败:接口地址或模型不存在,请检查 Provider 配置'
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
|
||||
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
|
||||
if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound')
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth')
|
||||
if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout')
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork')
|
||||
return raw
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { aiApi } from '../../api'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { state } from '../../stores/ai'
|
||||
import { resetStreamWatchdog } from './useAiStream'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId } from './useAiEvents'
|
||||
|
||||
@@ -107,8 +107,12 @@ function clearQueue() {
|
||||
state.queue = []
|
||||
}
|
||||
|
||||
/** 停止当前生成:仅发停止信号,streaming 状态由后端 AiCompleted 事件收尾 */
|
||||
/** 停止当前生成:本地先复位 streaming,再发停止信号。
|
||||
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
|
||||
*/
|
||||
async function stopChat() {
|
||||
state.streaming = false // 本地先复位,不依赖后端 AiCompleted(审批态看门狗已 clear,竞态丢失则卡死)
|
||||
clearStreamWatchdog() // 清残留看门狗
|
||||
await aiApi.stopChat()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,9 @@ export default {
|
||||
listDirectory: 'List Directory',
|
||||
writeFile: 'Write File',
|
||||
},
|
||||
errorNotFound: 'Request failed: the endpoint or model does not exist. Please check the Provider configuration.',
|
||||
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
|
||||
errorTimeout: 'The response timed out. Please try again.',
|
||||
errorNetwork: 'Network connection failed. Please check your network.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@ export default {
|
||||
listDirectory: '查看目录',
|
||||
writeFile: '写入文件',
|
||||
},
|
||||
errorNotFound: '调用失败:接口地址或模型不存在,请检查 Provider 配置',
|
||||
errorAuth: '调用失败:API Key 无效或无权限',
|
||||
errorTimeout: '响应超时,请重试',
|
||||
errorNetwork: '网络连接失败,请检查网络',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user