优化: 聊天体验批与Web规范合规
This commit is contained in:
@@ -153,9 +153,10 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
||||
}
|
||||
}
|
||||
_batchProcessing.value = false
|
||||
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
|
||||
// #14(2026-10-05):部分失败从静默 console.warn 升级为用户可见 toast(仍保留 warn 留痕诊断)
|
||||
if (failCount > 0) {
|
||||
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
|
||||
void emit('ai-toast', { msg: t('aiTool.batchPartialFail', { ok: successCount, fail: failCount }), type: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
@@ -15,12 +16,32 @@ import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApp
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, AiToolCallStatus, ConvId, MessageId } from '@/api/types'
|
||||
|
||||
// ── V11c(2026-10-05)活跃会话 KV 按窗口域分流 ──
|
||||
// 主窗与分离窗是独立 JS context,却共用同一 SQLite KV:两窗各自切会话都写 'df-ai-active-conv'
|
||||
// 会互相覆盖(后写者赢),重启后两窗恢复到同一会话。按窗口 label 分流:主窗 'df-ai-active-conv'
|
||||
// (历史 KV 兼容,主窗数据零迁移),分离窗 'df-ai-active-conv-detached';读端(loadConversations
|
||||
// 启动恢复)与写端(切会话/新建/自动路由/删除回落)统一走本函数取键。
|
||||
// 每次调用现解析:窗口对象在 webview 启动即存在(getCurrentWebviewWindow 同步),无启动竞态;
|
||||
// 非 Tauri 环境(单测/工具链)兜底主窗键。useAiEvents 的自动路由写入点同源 import 消费。
|
||||
export function activeConvKvKey(): string {
|
||||
try {
|
||||
return getCurrentWebviewWindow().label === 'main'
|
||||
? 'df-ai-active-conv'
|
||||
: 'df-ai-active-conv-detached'
|
||||
} catch {
|
||||
return 'df-ai-active-conv'
|
||||
}
|
||||
}
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
// includeArchived=true:侧栏归档分组(ConversationSidebar archivedConvs)数据源即本内存列表,
|
||||
// 若拉取时排除归档,重启后已归档会话不可见且无恢复入口(2026-10-05 实锤 bug)。
|
||||
// 活跃/归档分组由侧栏 groupedActive/archivedConvs 各自分离渲染;后端已分组截断,归档不挤占活跃名额。
|
||||
state.conversations = await aiApi.listConversations(50, true)
|
||||
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
|
||||
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
|
||||
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
|
||||
@@ -42,7 +63,7 @@ async function loadConversations() {
|
||||
})
|
||||
}
|
||||
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
|
||||
const pending = appSettings.get<string | null>('df-ai-active-conv', null)
|
||||
const pending = appSettings.get<string | null>(activeConvKvKey(), null)
|
||||
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
|
||||
await switchConversation(pending)
|
||||
}
|
||||
@@ -101,7 +122,7 @@ async function newConversation() {
|
||||
)
|
||||
if (!result) return // 失败已推气泡,保持当前视图
|
||||
state.activeConversationId = result.id
|
||||
void appSettings.set('df-ai-active-conv', result.id)
|
||||
void appSettings.set(activeConvKvKey(), result.id)
|
||||
// G3.5:新对话无历史,load_more 游标复位
|
||||
loadMoreCursor.hasMore = false
|
||||
loadMoreCursor.earliestSeq = null
|
||||
@@ -319,7 +340,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
|
||||
const created = await aiApi.createConversation()
|
||||
if (mySwitchId !== _latestSwitchId) return 'ok'
|
||||
void appSettings.set('df-ai-active-conv', created.id)
|
||||
void appSettings.set(activeConvKvKey(), created.id)
|
||||
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
|
||||
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
|
||||
state.activeConversationId = created.id
|
||||
@@ -418,7 +439,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
|
||||
return 'ok'
|
||||
}
|
||||
// 切换提交完成才持久化活跃会话 + 重算 streaming(active 已指向目标 conv)
|
||||
void appSettings.set('df-ai-active-conv', id)
|
||||
void appSettings.set(activeConvKvKey(), id)
|
||||
setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' })
|
||||
|
||||
// P2-2 完成卡回补:重启/分离窗回收后内存卡清零,按父 conv 反查子代理轨迹重建。
|
||||
@@ -552,7 +573,7 @@ async function deleteConversation(id: string) {
|
||||
clearLastDelta(id)
|
||||
discardConvPendingApprovals(id)
|
||||
if (wasActive) {
|
||||
void appSettings.remove('df-ai-active-conv')
|
||||
void appSettings.remove(activeConvKvKey())
|
||||
// 删除活跃会话 → load_more 游标复位(无 active 视图)
|
||||
loadMoreCursor.hasMore = false
|
||||
loadMoreCursor.earliestSeq = null
|
||||
|
||||
@@ -19,7 +19,7 @@ import { state } from '@/stores/ai'
|
||||
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
|
||||
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { scheduleConversationsRefresh } from './useAiConversations'
|
||||
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
|
||||
import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||
import { clearConvBoundaryCache } from './useTaskBoundary'
|
||||
import { handleStreamingEvent } from './useAiStreamingEvents'
|
||||
@@ -187,7 +187,7 @@ export function handleEvent(event: AiChatEvent) {
|
||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
|
||||
if (convId && !state.activeConversationId) {
|
||||
state.activeConversationId = convId
|
||||
void appSettings.set('df-ai-active-conv', convId)
|
||||
void appSettings.set(activeConvKvKey(), convId)
|
||||
}
|
||||
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
|
||||
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
|
||||
//! 打破 events↔send 循环依赖);本模块模块级监听该事件
|
||||
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
@@ -537,6 +537,12 @@ async function stopChat() {
|
||||
// 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
|
||||
// 后端 agentic/mod.rs emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
|
||||
// 当前未完成回复已入库(agentic/mod.rs save_conversation)保留为截断回复。
|
||||
// I8(2026-10-05):停止≠清队,用户预期常是「全部停下」——队列非空时一次性提示
|
||||
// 「停止后 N 条排队消息仍将自动发送」,给用户明确预期(要全停可逐条取消/清队列)。
|
||||
const pendingQueued = state.queue.filter(q => q.conversationId === state.activeConversationId).length
|
||||
if (pendingQueued > 0) {
|
||||
void emit('ai-toast', { msg: t('ai.queueWillContinue', { n: pendingQueued }), type: 'info' })
|
||||
}
|
||||
try {
|
||||
// F-260616-09 B 批4(决策 e):传 activeConversationId,停止仅作用于当前 conv。
|
||||
await aiApi.stopChat(state.activeConversationId)
|
||||
|
||||
@@ -25,10 +25,19 @@ export function handleToolEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
|
||||
if (!findToolCall(event.id)) {
|
||||
// #15(2026-10-05):末条非 assistant(如 system 占位/思考 pill/完成轨迹折叠重排)时
|
||||
// 原直接丢弃工具卡 → running 卡消失但工具仍在执行。兜底向前找最近 assistant 落卡;
|
||||
// 全程无 assistant(极端:首轮工具前无任何回复)才真丢弃(与原行为一致)。
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
let target = (lastMsg && lastMsg.role === 'assistant') ? lastMsg : null
|
||||
if (!target) {
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
if (state.messages[i].role === 'assistant') { target = state.messages[i]; break }
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
target.toolCalls = target.toolCalls || []
|
||||
target.toolCalls.push(info)
|
||||
}
|
||||
}
|
||||
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { state } from '@/stores/ai'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { nextMsgId, getConvState, convStates } from './aiShared'
|
||||
import type { MessageId } from '@/api/types'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
@@ -58,6 +59,45 @@ export async function emitDetachedOpenConversation(convId: string): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
// ── V11b(2026-10-05)跨窗收回分离窗:reattachPanel 的 UI 入口 ──
|
||||
// 病灶:reattachPanel(关闭全部分离窗+回主面板)全仓零 UI 调用——分离窗内「关闭」按钮走
|
||||
// closeDetachedWindow(仅关窗不恢复主面板),用户想收回只能手动重开主面板。主/分离窗是
|
||||
// 独立 JS context,分离窗按钮无法直调主窗 store;走 Tauri 全局事件广播(对齐 OPEN_CONV_EVENT
|
||||
// 模式):分离窗 emit → 主窗(label 判定)监听 → 执行 reattachPanel。
|
||||
const REATTACH_EVENT = 'ai-reattach-panel'
|
||||
|
||||
/** 发起方(分离窗)侧:广播收回事件,主窗口消费 */
|
||||
export async function emitReattachToMain(): Promise<void> {
|
||||
try {
|
||||
const { emit } = await import('@tauri-apps/api/event')
|
||||
await emit(REATTACH_EVENT, {})
|
||||
} catch (e) {
|
||||
console.error('[AI] 跨窗收回事件发送失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 承接方(主窗口)侧:注册收回监听。handler=reattachPanel(由 AiChat 挂载时注入,归窗口域) */
|
||||
let _reattachListenerRegistered = false
|
||||
export function registerMainReattachListener(handler: () => Promise<void>): void {
|
||||
if (_reattachListenerRegistered) return
|
||||
_reattachListenerRegistered = true
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ listen }, { getCurrentWebviewWindow }] = await Promise.all([
|
||||
import('@tauri-apps/api/event'),
|
||||
import('@tauri-apps/api/webviewWindow'),
|
||||
])
|
||||
if (getCurrentWebviewWindow().label !== 'main') return
|
||||
await listen<Record<string, never>>(REATTACH_EVENT, async () => {
|
||||
await handler()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[AI] 主窗口收回监听注册失败:', e)
|
||||
_reattachListenerRegistered = false
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** 承接方(分离窗)侧:注册跨窗切会话监听,切换完成后聚焦本窗口(点击发生在主窗口,
|
||||
* 焦点须跟到承接会话的窗口,否则用户感知「点了没反应」)。
|
||||
* handler 由 useAiConversations 注入:切会话逻辑归会话域,避免本模块反向依赖造成模块环。 */
|
||||
@@ -114,6 +154,12 @@ async function detachPanel(convId?: string) {
|
||||
localStorage.setItem(textKey(targetConv), state.currentText)
|
||||
}
|
||||
}
|
||||
// V11c: 种子分离窗活跃会话 KV——分离窗 AiChat 挂载同样跑 loadConversations(),
|
||||
// 读按窗口域分流的 'df-ai-active-conv-detached'(独立 JS context 不与主窗共 pinia state),
|
||||
// 不种子则分离窗启动回落列表首条而非主窗正看的会话。目标 conv 为空(无活跃会话)跳过。
|
||||
if (targetConv) {
|
||||
void useAppSettingsStore().set('df-ai-active-conv-detached', targetConv)
|
||||
}
|
||||
// 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
|
||||
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
|
||||
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
|
||||
@@ -133,7 +179,10 @@ async function detachPanel(convId?: string) {
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
|
||||
// V11a(2026-10-05):默认不置顶——原 true 会让分离窗盖住其他应用(VSCode/浏览器)妨碍工作;
|
||||
// 用户开过置顶的偏好走 AiChat.vue 的 df-ai-always-on-top KV 恢复链(分离窗挂载时
|
||||
// setAlwaysOnTop(saved)),默认改 false 不影响已开置顶用户,只改变首次/未开者的初始态。
|
||||
alwaysOnTop: false,
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
@@ -183,6 +232,8 @@ async function reattachPanel() {
|
||||
state.docked = false
|
||||
state.panelOpen = true
|
||||
persistUiState()
|
||||
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
|
||||
void useAppSettingsStore().remove('df-ai-active-conv-detached')
|
||||
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
|
||||
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
|
||||
stopListener()
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。
|
||||
|
||||
import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
|
||||
import { useMarkdown } from '../useMarkdown'
|
||||
import { useMarkdown, wrapNakedDiff } from '../useMarkdown'
|
||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
|
||||
/// 流式渲染返回的单块渲染结果(html + 模板 v-for key)
|
||||
@@ -122,7 +122,9 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
const purify = getPurify()
|
||||
if (!marked || !purify) return escapeFallback(block)
|
||||
try {
|
||||
return purify.sanitize(marked.parse(block) as string)
|
||||
// R8:流式块同源裸 diff 预处理(原仅完成态 renderMd 有),流式中裸 +/- 行即红绿代码块,
|
||||
// 不再等完成态突变丢阅读锚点。守卫3(围栏内不动)保证与完成态 renderMd 幂等不双重包裹。
|
||||
return purify.sanitize(marked.parse(wrapNakedDiff(block)) as string)
|
||||
} catch {
|
||||
return escapeFallback(block)
|
||||
}
|
||||
@@ -150,7 +152,8 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
|
||||
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
|
||||
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
|
||||
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
|
||||
// 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)→ 三元组清零走统一入口(DRY)
|
||||
resetIncrementalBaseline()
|
||||
return [{ html: escapeFallback(text), key: 'fallback' }]
|
||||
}
|
||||
// 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。
|
||||
@@ -159,13 +162,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
let blocks: string[]
|
||||
if (appendOnly) {
|
||||
const growth = text.slice(_prevStreamText.length)
|
||||
const combined = _prevTail + growth
|
||||
const combinedBlocks = splitBlocks(combined)
|
||||
blocks = combinedBlocks.length <= 1
|
||||
// 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长
|
||||
? _prevCompleted.concat(combinedBlocks)
|
||||
// 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块
|
||||
: _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1])
|
||||
// splitBlocks 输出无论 1 块(旧末块未跨边界,新末块=旧末块+增长)还是 N 块
|
||||
//(旧末块跨边界拆出若干已完成块+新末块),语义均为「追加到已完成块序列之后」,
|
||||
// 原 if/else 两分支 concat 结果恒等价 → 收敛单 concat(死分支清除,DRY)。
|
||||
blocks = _prevCompleted.concat(splitBlocks(_prevTail + growth))
|
||||
} else {
|
||||
blocks = splitBlocks(text)
|
||||
}
|
||||
@@ -240,6 +240,14 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
})
|
||||
}
|
||||
|
||||
/// 三元组增量基线清零(唯一写入口):纯文本降级路径/清流式状态/清块缓存三处共用。
|
||||
/// 手抄三行式已废弃,清基线必须走本函数(DRY 2026-10-05)。
|
||||
function resetIncrementalBaseline(): void {
|
||||
_prevStreamText = ''
|
||||
_prevCompleted = []
|
||||
_prevTail = ''
|
||||
}
|
||||
|
||||
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
|
||||
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。
|
||||
function clearStreamingState(): void {
|
||||
@@ -247,9 +255,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
streamingBlocks.value = []
|
||||
lastStreamText = ''
|
||||
// 清增量基线(防新轮/新会话误命中旧帧已完成块)。
|
||||
_prevStreamText = ''
|
||||
_prevCompleted = []
|
||||
_prevTail = ''
|
||||
resetIncrementalBaseline()
|
||||
}
|
||||
|
||||
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
|
||||
@@ -263,9 +269,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
/// 同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
|
||||
function resetStreamCache(): void {
|
||||
_blockCache.clear()
|
||||
_prevStreamText = ''
|
||||
_prevCompleted = []
|
||||
_prevTail = ''
|
||||
resetIncrementalBaseline()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -205,6 +205,10 @@ function renderMd(text: string): string {
|
||||
* 本函数在 marked.parse 前扫描文本,把「连续 ≥2 行以 + 或 - 开头、且至少含 1 行 +」的
|
||||
* 段落整段包成 ```diff 围栏代码块,复用②子项已就绪的 hljs diff 红绿样式,零额外 CSS。
|
||||
*
|
||||
* R8(2026-10-05)改 export:流式渲染链(useStreamRenderer.renderStreamingBlocks)同源复用,
|
||||
* 治「流式中裸 diff 渲染为普通段落,完成后突变红绿代码块」的阅读锚点丢失。
|
||||
* 幂等安全:守卫3(围栏内不动)保证流式已包的块,完成态 renderMd 再处理不双重包裹。
|
||||
*
|
||||
* 守卫:
|
||||
* 1. **纯 - 列表不触发**: 普通 markdown ul 每行 `- ` 是常见语法,纯 - 会误伤成代码块。
|
||||
* 故要求段内至少含 1 行 `+ `(+ 必须含),这是裸 diff 区别于普通列表的强特征。
|
||||
@@ -221,7 +225,7 @@ const _DIFF_LINE_RE = /^[ \t]*[+-][^\n]*$/ // 行首可选空格/tab + +/- + 任
|
||||
const _PLUS_LINE_RE = /^[ \t]*\+[^\n]*$/ // 含 + 的行(+ 必须含的判定)
|
||||
const _FENCE_OPEN_RE = /^[ \t]{0,3}(```|~~~)/ // marked 围栏开头(行首 ≤3 空格 + 3+ 反引号/波浪)
|
||||
|
||||
function wrapNakedDiff(text: string): string {
|
||||
export function wrapNakedDiff(text: string): string {
|
||||
if (!text) return text
|
||||
// 快速短路:全文无 +/- 行首特征直接返回原文(避免大文本白跑切段)
|
||||
if (!/[+-]/.test(text)) return text
|
||||
|
||||
Reference in New Issue
Block a user