新增: 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
142
src/components/ai/DirAuthDialog.vue
Normal file
142
src/components/ai/DirAuthDialog.vue
Normal 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>
|
||||
@@ -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(供错误气泡差异化显隐「去设置」按钮)。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user