新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C

会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测

文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09  PASS
This commit is contained in:
2026-06-19 18:58:48 +08:00
parent 968c3df5ab
commit 8da6bdcec2
17 changed files with 1572 additions and 17 deletions

View File

@@ -68,6 +68,15 @@ export const aiApi = {
return invoke('ai_approve', { toolCallId, approved })
},
/**
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。
*/
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> {
return invoke('ai_authorize_dir', { toolCallId, decision })
},
/**
* 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn

View File

@@ -293,6 +293,10 @@ export type AiChatEvent = ({
} | {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
type: 'AiMaxRoundsReached'
} | {
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
} | {
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
type: 'AiStreamRetry'; attempt: number; max_attempts: number

View File

@@ -58,6 +58,8 @@
</div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny) -->
<DirAuthDialog @toast="(p) => showToast(p.msg, p.type)" />
<!-- 待发送队列(生成中排队的消息,完成后自动续发) -->
<div v-if="store.state.queue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
<div class="ai-queue-head">
@@ -131,6 +133,7 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useProjectStore } from '../stores/project'
import type { AiMessage } from '../api/types'

View File

@@ -0,0 +1,142 @@
<template>
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)
条件:pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)
"仅本次" ai_authorize_dir(decision=once, 写会话临时授权) 后端执行工具 + try_continue
"未来都允许" ai_authorize_dir(decision=always, 写持久化 Settings KV) 执行 + loop
"拒绝" ai_authorize_dir(decision=deny, 工具返 Err) loop
store 单例共享(state/aiApi),pendingDirAuth 模块级 ref(useAiEvents)直接导入
toast emit 转父(保持单一 toast ) -->
<div v-if="showDirAuthCard" class="ai-dir-auth">
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
<span class="ai-dir-auth-hint">
{{ $t('aiChat.dirAuthHint', { tool: pendingDirAuth?.tool, path: pendingDirAuth?.path }) }}
</span>
<div class="ai-dir-auth-actions">
<button
class="ai-dir-auth-btn ai-dir-auth-btn--once"
:disabled="dirAuthActing"
@click="handleAuthorize('once')"
>{{ $t('aiChat.dirAuthOnce') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--always"
:disabled="dirAuthActing"
@click="handleAuthorize('always')"
>{{ $t('aiChat.dirAuthAlways') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
:disabled="dirAuthActing"
@click="handleAuthorize('deny')"
>{{ $t('aiChat.dirAuthDeny') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingDirAuth } from '../../composables/ai/useAiEvents'
import { aiApi } from '../../api'
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 当前视图是否正在生成(切走后台生成时光标不显示,与 MaxRoundsCard 一致守卫)
const isViewingGenerating = computed(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId,
)
// pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。
const dirAuthActing = ref(false)
const showDirAuthCard = computed(() =>
pendingDirAuth.value !== null && isViewingGenerating.value,
)
/** 点三选项之一:调 ai_authorize_dir。后端 remove pending → 写授权/拒 → execute → try_continue。
* 不主动清 pendingDirAuth——由后端 AiApprovalResult/AiCompleted/AiError 在 useAiEvents 内清。
* IPC 失败回滚 acting 让用户可重试。 */
async function handleAuthorize(decision: 'once' | 'always' | 'deny'): Promise<void> {
const id = pendingDirAuth.value?.id
if (!id) return
dirAuthActing.value = true
try {
await aiApi.authorizeDir(id, decision)
} catch (e) {
dirAuthActing.value = false
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
}
}
/** pendingDirAuth 离开挂起态(后端事件已清)时复位 acting,允许下次再操作 */
watch(() => pendingDirAuth.value, (v) => {
if (!v) dirAuthActing.value = false
})
</script>
<style scoped>
.ai-dir-auth {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
border-radius: var(--df-radius);
}
.ai-dir-auth-text {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-dir-auth-hint {
font-size: 10.5px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-all;
}
.ai-dir-auth-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
flex-wrap: wrap;
}
.ai-dir-auth-btn {
border: none;
cursor: pointer;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: calc(var(--df-radius) - 2px);
transition: filter 0.15s var(--df-ease);
}
.ai-dir-auth-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ai-dir-auth-btn--once {
background: var(--df-accent);
color: #fff;
}
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--always {
background: var(--df-warning);
color: #000;
}
.ai-dir-auth-btn--always:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--deny {
background: transparent;
color: var(--df-text-dim);
border: 0.5px solid var(--df-border);
}
.ai-dir-auth-btn--deny:hover:not(:disabled) {
color: var(--df-text);
border-color: var(--df-text-dim);
}
</style>

View File

@@ -43,7 +43,8 @@ const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached'])
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -97,6 +98,20 @@ function clearAllToolSlowTimers(): void {
// 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false)
// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。
//
// 同 pendingMaxRounds 模式:模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件
// 直接 import 读。一次只追踪一个挂起询问(后端单 tool_call_id 挂起,用户决策后 try_continue),
// 故用对象 ref 而非数组,语义更精确。null = 无挂起。
export interface PendingDirAuth {
id: string
tool: string
path: string
dir: string
conversationId?: string
}
export const pendingDirAuth = ref<PendingDirAuth | null>(null)
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
emit('ai-conversation-changed', {})
@@ -198,6 +213,31 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
pendingMaxRounds.value = true
return true
case 'AiDirAuthRequired': {
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
// 置 pendingDirAuth 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
// 同时把工具卡置 pending_approval 态(与 AiApprovalRequired 一致,复用 ToolCard 渲染)。
clearStreamWatchdog()
const info: AiToolCallInfo = {
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
if (tc) tc.status = 'pending_approval'
clearToolSlowTimer(event.id)
pendingDirAuth.value = {
id: event.id,
tool: event.tool,
path: event.path,
dir: event.dir,
conversationId: event.conversation_id,
}
return true
}
default:
return false
}
@@ -289,6 +329,11 @@ function handleToolEvent(event: AiChatEvent): boolean {
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
}
// F-260619-03 Phase B: 路径授权挂起的工具收到 ApprovalResult(once/always 通过 / deny 拒绝)
// → 清弹窗(后端 ai_authorize_dir 已 try_continue 续 loop)。
if (pendingDirAuth.value && pendingDirAuth.value.id === event.id) {
pendingDirAuth.value = null
}
return true
}
@@ -309,6 +354,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留)
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
@@ -358,6 +404,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。

View File

@@ -145,6 +145,14 @@ export default {
continueLoopFailed: 'Continue failed: {msg}',
stopLoopFailed: 'Stop failed: {msg}',
// ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ──
dirAuthRequired: 'Path authorization required',
dirAuthHint: '{tool} wants to access: {path}',
dirAuthOnce: 'This time',
dirAuthAlways: 'Always allow',
dirAuthDeny: 'Deny',
dirAuthFailed: 'Authorization failed: {msg}',
// ── UX-18: conversation export (sidebar hover, pick format to download) ──
exportConversation: 'Export chat',
exportMarkdown: 'Markdown',

View File

@@ -146,6 +146,14 @@ export default {
continueLoopFailed: '继续失败:{msg}',
stopLoopFailed: '停止失败:{msg}',
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
dirAuthRequired: '需要路径授权',
dirAuthHint: '{tool} 想访问:{path}',
dirAuthOnce: '仅本次',
dirAuthAlways: '未来都允许',
dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}',
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
exportConversation: '导出对话',
exportMarkdown: 'Markdown',