新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏

- 待发箱地基: V51 ai_outbox 表 + 手写 OutboxRepo(claim CAS/崩溃残留复位/瞬时失败保留) + outbox.rs 调度器(KV 默认关/Notify 唤醒/会话忙跳过不丢)
- Wake 异步唤醒原语: 三条件(delay/file-appear/process-exit)+纯判定+注入走正典 spawn_agentic_session,默认关
- 上下文预算随模型窗口: model_context.rs 三级优先(池显式>预设表>回退 128K);上界取 1M 而非 128K——max_tokens 仅裁剪预算,卡 128K 等于不治根因
- 文件租约补纯函数与单测(未另造双轨:LEASE 早随 cb260ab 落地) / 跨会话召回补纯函数单测(主体早已落地)
- 子代理: 完整卡已消化 + 子会话压缩层 + P3 批量
- 隧道/中继: 指令离线不丢 + Hello proto 版本 + 心跳与 runbook
- df-mcp: 写工具后落 KV 变更标记(供 GUI 轮询兜底,补独立 stdio 进程无感知)
- 迁移链顶点改单一真值常量 LATEST_SCHEMA_VERSION(修 2 处因 V51 失效的断言)
- 治理: i18n 特殊字符提交门禁(含反引号覆盖 + 10 自测) / 根目录卫生门禁 / 看板归档器 / 单一 lockfile(bun) / swap-install 落 temp/ 且兼容无 Get-FileHash
- 场景护栏: 流式正文可见性 e2e(playwright 免真机事件回放) + 列表续写/外置句柄 node 测试
This commit is contained in:
lxy
2026-10-10 08:09:17 +08:00
parent 9c68f98d89
commit b37279f3a2
32 changed files with 4175 additions and 152 deletions
+105
View File
@@ -0,0 +1,105 @@
<template>
<!-- D2a 外置结果条(工具卡 + 审计详情共用):标记 + 大小/工具 + 句柄路径 + 展开读回按钮。
纯展示,展开/读回状态由调用方经 useOffloadedResult 持有并透传。 -->
<div class="offload">
<div class="offload-bar">
<span class="offload-badge">{{ $t('aiTool.resultOffloaded') }}</span>
<span v-if="sizeText" class="offload-meta">{{ sizeText }}</span>
<span v-if="handle.tool" class="offload-meta">{{ handle.tool }}</span>
<code class="offload-path" :title="handle.path">{{ handle.path }}</code>
<button type="button" class="offload-toggle" :disabled="loading" @click="emit('toggle')">
{{ loading ? $t('aiTool.running') : (expanded ? $t('aiTool.collapse') : $t('aiTool.expand')) }}
</button>
</div>
<div v-if="error" class="offload-error"><span aria-hidden="true">⚠</span> {{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatBytes } from '@/composables/ai/useToolCard'
import type { OffloadHandle } from '@/composables/ai/useOffloadedResult'
const props = defineProps<{
/** 已识别的外置句柄(非空;调用方判定后再渲染本组件) */
handle: OffloadHandle
/** 展开态(由调用方状态机持有) */
expanded: boolean
/** 读回中 */
loading: boolean
/** 读回失败信息(空串=无错误) */
error: string
}>()
const emit = defineEmits<{
/** 展开/收起:调用方负责切换展开态并按需触发 IPC 读回 */
toggle: []
}>()
/** 句柄大小的人类可读文本(缺 size_bytes 时隐藏) */
const sizeText = computed(() =>
typeof props.handle.sizeBytes === 'number' ? formatBytes(props.handle.sizeBytes) : '',
)
</script>
<style scoped>
.offload {
border-top: 0.5px solid var(--df-border);
}
.offload-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--df-warning-bg, rgba(240,199,94,0.08));
}
.offload-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: var(--df-radius-sm);
background: var(--df-warning-bg, rgba(240,199,94,0.14));
color: var(--df-warning, #f0c75e);
}
.offload-meta {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
.offload-path {
flex: 1;
min-width: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offload-toggle {
flex-shrink: 0;
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
cursor: pointer;
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.offload-toggle:hover:not(:disabled) {
color: var(--df-text-secondary);
background: rgba(255,255,255,0.08);
border-color: var(--df-border-strong);
}
.offload-toggle:disabled { opacity: 0.6; cursor: not-allowed; }
.offload-error {
padding: 4px 10px;
font-size: 11px;
color: var(--df-danger);
background: var(--df-danger-bg);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
//! 输入框列表续写 — 纯逻辑层(无 Vue/无副作用,供 ChatInput 接线 + node --test 单测)。
//!
//! 需求锚点: 输入框列表预览与自动编号设计(docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md)P1=V0。
//! 能力: 识别行首列表标记 → Shift+Enter 续写补下一序号/同型符号 → 空项 Shift+Enter / 末位退格退出列表。
//! 边界: 仅改输入文本与光标位,不碰发送链/渲染链(消息仍是含字面标记的 markdown 原文,marked 本渲染为列表);
//! ` ``` ` 围栏内不触发(避免把代码里的 `- x` 当列表)。
//!
//! 为什么拆成纯函数: textarea 无法在框内渲染真列表(圆点/悬挂缩进),故只做逻辑层,
//! textarea 渲染零改动(零视觉);纯函数也便于脱离浏览器环境单测。
export interface ListContext {
kind: 'ordered' | 'unordered'
/** 行首缩进(≤3 空格,原样保留) */
indent: string
/** 字面标记(含标记后单空格),如 "3. " / "- " */
marker: string
/** ordered 的序号 */
number?: number
/** unordered 的符号字符 */
bullet?: '-' | '*' | '+'
/** 标记后的正文(可空) */
content: string
/** 该行在 text 中的起始偏移 */
lineStart: number
/** 标记结束偏移(标记后第一个正文字符) */
markerEnd: number
}
/** 编辑结果:新文本 + 新光标位(调用方据此重设 selection 并显式平移 mention span) */
export interface ListEditResult {
text: string
caret: number
}
// 有序: ≤3 空格缩进 + 1-9 位数字 + `.`/`)` + 空白(兼容 markdown 两种有序标记)
const ORDERED_RE = /^(\s{0,3})(\d{1,9})([.)])\s(.*)$/
// 无序: ≤3 空格缩进 + `-`/`*`/`+` + 空白
const UNORDERED_RE = /^(\s{0,3})([-*+])\s(.*)$/
// 代码围栏行(``` 或 ~~~,≤3 缩进);与 useMarkdown.wrapNakedDiff 同款判定
const FENCE_RE = /^\s{0,3}(?:```|~~~)/
/** 统计 before 段内代码围栏行数;奇数 → 光标处于未闭合围栏内 */
function insideFence(before: string): boolean {
let count = 0
for (const line of before.split('\n')) {
if (FENCE_RE.test(line)) count++
}
return count % 2 === 1
}
/**
* 判定光标所在行的列表语境;非列表行 / 围栏内 / 光标越界返回 null。
* 只按 `\n` 定位光标所在行,不要求光标在行末(续写可从行中任意位置切开)。
*/
export function analyzeListContext(text: string, caret: number): ListContext | null {
if (caret < 0 || caret > text.length) return null
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
// 围栏守卫只扫当前行之前,当前行是标记行本身
if (insideFence(text.slice(0, lineStart))) return null
const nl = text.indexOf('\n', caret)
const line = text.slice(lineStart, nl === -1 ? text.length : nl)
const om = ORDERED_RE.exec(line)
if (om) {
const marker = `${om[2]}${om[3]} `
return {
kind: 'ordered',
indent: om[1],
marker,
number: parseInt(om[2], 10),
content: om[4],
lineStart,
markerEnd: lineStart + om[1].length + marker.length,
}
}
const um = UNORDERED_RE.exec(line)
if (um) {
const bullet = um[2] as '-' | '*' | '+'
const marker = `${bullet} `
return {
kind: 'unordered',
indent: um[1],
marker,
bullet,
content: um[3],
lineStart,
markerEnd: lineStart + um[1].length + marker.length,
}
}
return null
}
/** 续写标记:ordered → 序号 +1(保留 `.`/`)` 原样式);unordered → 同型符号 */
export function nextMarker(ctx: ListContext): string {
if (ctx.kind === 'unordered') return ctx.marker
return ctx.marker.replace(/^\d+/, String((ctx.number ?? 0) + 1))
}
/**
* Shift+Enter 续写:非空列表项 → 换行并补下一标记;空列表项 → 退出列表(删标记留空行)。
* 返回 null 表示非列表语境(交默认换行)。
*/
export function continueList(text: string, caret: number): ListEditResult | null {
const ctx = analyzeListContext(text, caret)
if (!ctx) return null
if (ctx.content.trim() === '') {
// 空项 → 退出列表:剥掉 [lineStart, markerEnd) 的标记,光标落在删后同相对位
const cut = ctx.markerEnd - ctx.lineStart
const next = text.slice(0, ctx.lineStart) + text.slice(ctx.markerEnd)
return { text: next, caret: Math.max(ctx.lineStart, caret - cut) }
}
const insert = '\n' + ctx.indent + nextMarker(ctx)
return { text: text.slice(0, caret) + insert + text.slice(caret), caret: caret + insert.length }
}
/**
* 末位退格退出空列表项:光标须在行末且当前行为空列表项(仅标记+空白,无正文)。
* 返回 null 表示不适用(交默认退格,正常删字)。
*/
export function exitListOnBackspace(text: string, caret: number): ListEditResult | null {
if (caret <= 0) return null
const nl = text.indexOf('\n', caret)
if (caret !== (nl === -1 ? text.length : nl)) return null
const ctx = analyzeListContext(text, caret)
if (!ctx || ctx.content.trim() !== '') return null
const next = text.slice(0, ctx.lineStart) + text.slice(ctx.markerEnd)
return { text: next, caret: ctx.lineStart }
}
+119
View File
@@ -0,0 +1,119 @@
//! D2a 工具结果外置句柄的前端识别与读回。
//!
//! 后端审计写路径对超 8KB 的工具结果落盘到 {app_data}/tool-results/**,DB 只留句柄 JSON:
//! {"__offloaded__":true,"path":"...","size_bytes":N,"tool":"...","head":"...","hint":"..."}
//! 前端遇此句柄时显示「结果已外置」+ 展开;展开经 IPC ai_read_offloaded_result 按 path 读回原文。
//!
//! 单一真值来源:句柄识别/读回/IPC 参数名在此收口,工具卡与审计详情共用,不各自实现一份。
//! 两个来源形态:工具卡 tc.result 是完整句柄(JSON 合法);审计 result_brief 被后端截断到
//! 160 字符(JSON 非法、尾部省略号),故识别分完整解析与截断正则兜底两路。
import { computed, ref, type ComputedRef } from 'vue'
import { invoke } from '@tauri-apps/api/core'
/** 句柄标记字段(与后端 tool_result_offload::HANDLE_MARK 同字面量,改动须同步) */
export const OFFLOAD_MARK = '__offloaded__'
/** 外置句柄(后端 json! 落库;此处只取前端展示/读回需要的字段) */
export interface OffloadHandle {
path: string
sizeBytes?: number
tool?: string
head?: string
}
/** 从截断句柄文本里宽松提取 path(JSON 已非法,不能 JSON.parse)。 */
function extractPathTolerant(s: string): string | null {
const m = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s)
if (!m) return null
// JSON 字符串反转义(Windows 路径含 \\ 转义;路径中不会出现 \n/\t 等序列)
return m[1].replace(/\\(.)/g, '$1')
}
/** 对象形态 → 句柄(标记须为 true 且含非空 path,否则 null)。 */
function handleFromObject(obj: unknown): OffloadHandle | null {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null
const o = obj as Record<string, unknown>
if (o[OFFLOAD_MARK] !== true) return null
const path = o.path
if (typeof path !== 'string' || !path) return null
return {
path,
sizeBytes: typeof o.size_bytes === 'number' ? o.size_bytes : undefined,
tool: typeof o.tool === 'string' ? o.tool : undefined,
head: typeof o.head === 'string' ? o.head : undefined,
}
}
/** result 是否带外置标记(不要求 path 可解析;供列表单元格/头部摘要轻量判据)。 */
export function hasOffloadMark(result: unknown): boolean {
if (typeof result === 'string') return /"__offloaded__"\s*:\s*true/.test(result)
if (result && typeof result === 'object' && !Array.isArray(result)) {
return (result as Record<string, unknown>)[OFFLOAD_MARK] === true
}
return false
}
/**
* 识别外置句柄:非句柄 / 无 path → null(调用方按普通结果渲染)。
* 完整句柄走 JSON 解析;截断句柄(审计 result_brief)JSON 非法 → 标记 + 正则抽 path 兜底。
*/
export function parseOffloadHandle(result: unknown): OffloadHandle | null {
if (!hasOffloadMark(result)) return null
if (typeof result === 'string') {
try {
return handleFromObject(JSON.parse(result))
} catch {
const path = extractPathTolerant(result)
return path ? { path } : null
}
}
return handleFromObject(result)
}
/** 按 path 读回外置原文(后端 IPC ai_read_offloaded_result;文件缺失返带语义错误串)。 */
export function readOffloadedResult(path: string): Promise<string> {
return invoke<string>('ai_read_offloaded_result', { path })
}
/**
* 外置结果展开状态机:识别句柄 + 懒加载读回原文(同一 path 只读一次,读回后缓存)。
* getResult 惰性取值,响应式来源不在此固化(工具卡按 tc、审计按当前展开行)。
*/
export function useOffloadedResult(getResult: () => unknown) {
const handle: ComputedRef<OffloadHandle | null> = computed(() => parseOffloadHandle(getResult()))
const expanded = ref(false)
const loading = ref(false)
const error = ref('')
const resolved = ref<string | null>(null)
async function toggle() {
if (expanded.value) {
expanded.value = false
return
}
expanded.value = true
const h = handle.value
// 已读回 / 读回中 / 无句柄 → 仅切换展开态,不重复走 IPC
if (!h || resolved.value != null || loading.value) return
loading.value = true
error.value = ''
try {
resolved.value = await readOffloadedResult(h.path)
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
/** 展开对象切换(审计单行详情复用同一实例)时清态,防串上一行的读回结果/错误。 */
function reset() {
expanded.value = false
loading.value = false
error.value = ''
resolved.value = null
}
return { handle, expanded, loading, error, resolved, toggle, reset }
}