优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底 - 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、 conversation-sidebar/ConversationList、settings/provider-panel - 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补 fill-draft父侧转发(重构半成品断链修复) - 验收:vue-tsc EXIT 0
This commit is contained in:
@@ -108,7 +108,9 @@ function onDismiss() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
|
||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态
|
||||
wave2(2026-10-18):danger 系透明彩底范式(待审批=需用户处置的阻断信号)——
|
||||
弹层底 bg-card+边框+10px 圆角,边框 color-mix 30%+左缘 3px 强调,标题同色 12px/600 */
|
||||
.approval-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
@@ -121,10 +123,10 @@ function onDismiss() {
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-left: 3px solid var(--df-danger);
|
||||
border-radius: var(--df-radius-lg);
|
||||
box-shadow: var(--df-shadow-md), 0 0 0 1px color-mix(in srgb, var(--df-danger) 12%, transparent);
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
|
||||
user-select: none;
|
||||
@@ -132,8 +134,8 @@ function onDismiss() {
|
||||
|
||||
.approval-overlay:hover {
|
||||
transform: translateX(-2px);
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 55%, transparent);
|
||||
box-shadow: var(--df-shadow-md), 0 0 0 2px color-mix(in srgb, var(--df-danger) 45%, transparent);
|
||||
}
|
||||
|
||||
.approval-overlay-icon {
|
||||
@@ -144,8 +146,8 @@ function onDismiss() {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
color: var(--df-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -172,15 +174,15 @@ function onDismiss() {
|
||||
}
|
||||
|
||||
.approval-overlay-title {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
color: var(--df-danger);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.approval-overlay-desc {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -190,8 +192,8 @@ function onDismiss() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: var(--df-h-icon-btn);
|
||||
height: var(--df-h-icon-btn);
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
@@ -202,7 +204,7 @@ function onDismiss() {
|
||||
}
|
||||
|
||||
.approval-overlay-close:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ onMounted(async () => {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
background: var(--df-bg-secondary);
|
||||
background: var(--df-bg-card);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ci-header {
|
||||
|
||||
+226
-106
@@ -45,81 +45,86 @@
|
||||
@select="selectMention"
|
||||
@update:mention-index="mentionIndex = $event"
|
||||
/>
|
||||
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickImage')"
|
||||
:aria-label="$t('aiChat.pickImage')"
|
||||
@click="onPickImage"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
<!-- I9(2026-10-05):文本/代码文件附件入口——选文件读内容以 ```<file> 块插入草稿,
|
||||
走既有文本发送链,零协议改动;大小/行数上限防刷屏 -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickFileTitle')"
|
||||
:aria-label="$t('aiChat.pickFileTitle')"
|
||||
@click="onPickTextFile"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
</button>
|
||||
<!-- 定时/待发箱入口(设计 2026-10-10 §3.4):icon-only ⏰ 开 OutboxDrawer
|
||||
(组件内 Teleport 到 body,不占输入区布局;抽屉组件标签在模板尾部) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.outboxEntry')"
|
||||
:aria-label="$t('aiChat.outboxEntry')"
|
||||
@click="outboxOpen = true"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</button>
|
||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
v-if="stopBtnState === 'stopping'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||
:title="$t('aiChat.stopping')"
|
||||
:aria-label="$t('aiChat.stopping')"
|
||||
disabled
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'retry'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||
:title="$t('aiChat.stopFailedRetry')"
|
||||
:aria-label="$t('aiChat.stopFailedRetry')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'stop'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop"
|
||||
:title="$t('aiChat.stopGenerating')"
|
||||
:aria-label="$t('aiChat.stopGenerating')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ai-send-btn"
|
||||
:class="{ 'ai-send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:aria-label="$t('aiChat.queueSendNow')"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
<!-- 2026-10-18 wave2: 工具行(workbench .inp-row 范式)——textarea 下方分隔线,
|
||||
左侧功能钮 + 右侧发送/停止钮;纯布局重排,按钮类名/事件/禁用判定零变化 -->
|
||||
<div class="ai-input-row">
|
||||
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickImage')"
|
||||
:aria-label="$t('aiChat.pickImage')"
|
||||
@click="onPickImage"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
<!-- I9(2026-10-05):文本/代码文件附件入口——选文件读内容以 ```<file> 块插入草稿,
|
||||
走既有文本发送链,零协议改动;大小/行数上限防刷屏 -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickFileTitle')"
|
||||
:aria-label="$t('aiChat.pickFileTitle')"
|
||||
@click="onPickTextFile"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
</button>
|
||||
<!-- 定时/待发箱入口(设计 2026-10-10 §3.4):icon-only ⏰ 开 OutboxDrawer
|
||||
(组件内 Teleport 到 body,不占输入区布局;抽屉组件标签在模板尾部) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.outboxEntry')"
|
||||
:aria-label="$t('aiChat.outboxEntry')"
|
||||
@click="outboxOpen = true"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</button>
|
||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
v-if="stopBtnState === 'stopping'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||
:title="$t('aiChat.stopping')"
|
||||
:aria-label="$t('aiChat.stopping')"
|
||||
disabled
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'retry'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||
:title="$t('aiChat.stopFailedRetry')"
|
||||
:aria-label="$t('aiChat.stopFailedRetry')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'stop'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop"
|
||||
:title="$t('aiChat.stopGenerating')"
|
||||
:aria-label="$t('aiChat.stopGenerating')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ai-send-btn"
|
||||
:class="{ 'ai-send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:title="$t('aiChat.send')"
|
||||
:aria-label="$t('aiChat.send')"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
|
||||
<EnrichmentPanel
|
||||
@@ -132,6 +137,12 @@
|
||||
/>
|
||||
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
|
||||
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
|
||||
<!-- F2-4(走查-信息UX 面二):示例卡覆盖未发送草稿的二次确认弹层(组件自持,见 setup 注释) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -145,7 +156,7 @@
|
||||
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
|
||||
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
|
||||
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
@@ -162,6 +173,8 @@ import ImageInput from './ImageInput.vue'
|
||||
import MentionPopover from './MentionPopover.vue'
|
||||
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||
import OutboxDrawer from './OutboxDrawer.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useConfirm } from '../../composables/useConfirm'
|
||||
import { taskApi } from '../../api/task'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||
import type { MentionItem } from './MentionItem'
|
||||
@@ -1078,6 +1091,8 @@ async function handleSend() {
|
||||
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
||||
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
||||
inputText.value = ''
|
||||
// F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿)
|
||||
persistDraftNow()
|
||||
pendingSkill.value = null
|
||||
pendingImages.value = []
|
||||
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
|
||||
@@ -1113,12 +1128,20 @@ async function handleSend() {
|
||||
emit('sent')
|
||||
}
|
||||
|
||||
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */
|
||||
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致)。
|
||||
* F2-4(走查-信息UX 面二):输入框已有未发送草稿(文本/图片/技能 chip 任一)时先确认,
|
||||
* 取消则原样保留草稿——对齐父级 backfillQueued 的覆盖确认语义,不再静默替换。 */
|
||||
async function sendExamplePrompt(i18nKey: string) {
|
||||
if (hasUnsentInput() && !(await confirmDialog(t('aiChat.exampleOverwriteConfirm')))) return
|
||||
inputText.value = t(i18nKey)
|
||||
await handleSend()
|
||||
}
|
||||
|
||||
// ── F2-4:覆盖确认弹层(仓风格不引 window.confirm;AiChat 的 confirmDialog 状态机在父级
|
||||
// 经模板绑定,子组件复用需父接线 → 本组件自持一份 useConfirm + ConfirmDialog,
|
||||
// 行为/样式与父级 backfillQueued / clearQueue 确认同范式) ──
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
|
||||
|
||||
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
|
||||
@@ -1140,7 +1163,9 @@ function startEditFocus(msg: AiMessage): void {
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||
// F2-3(走查-信息UX):编辑态回填高度复用 INPUT_MAX_HEIGHT,与 autoResize 同源;
|
||||
// 原硬编码 120 使长草稿进编辑态首屏被压矮,任一按键才弹回 200
|
||||
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1155,6 +1180,8 @@ function clearInput(): void {
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||
// F2-2:清空即落盘(取消编辑不应残留旧稿到下次重启)
|
||||
persistDraftNow()
|
||||
}
|
||||
|
||||
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
|
||||
@@ -1184,12 +1211,78 @@ function focus(): void {
|
||||
}
|
||||
|
||||
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
|
||||
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
|
||||
// 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像:
|
||||
// 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的
|
||||
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
|
||||
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
|
||||
|
||||
// F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条,
|
||||
// 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为,
|
||||
// 解析失败/配额满不阻断输入主链。
|
||||
const DRAFTS_KEY = 'df-ai-conv-drafts'
|
||||
const DRAFTS_MAX = 20
|
||||
|
||||
function readDraftsStore(): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(DRAFTS_KEY) || '{}')
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistDraft(convId: string, text: string): void {
|
||||
const all = readDraftsStore()
|
||||
if (text) {
|
||||
// 删后重插把该会话挪到键序末尾,Object 键序=写入序,溢出时从头淘汰即 LRU
|
||||
delete all[convId]
|
||||
all[convId] = text
|
||||
const keys = Object.keys(all)
|
||||
for (let i = 0; i < keys.length - DRAFTS_MAX; i++) delete all[keys[i]]
|
||||
} else {
|
||||
delete all[convId]
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(all))
|
||||
} catch {
|
||||
// 配额满等:放弃本次落盘,内存草稿不受影响
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖落盘:逐键写盘不必要,300ms 静默期合并;发送/清空/切会话/卸载走立即落盘收口
|
||||
let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function persistDraftNow(): void {
|
||||
if (draftPersistTimer) {
|
||||
clearTimeout(draftPersistTimer)
|
||||
draftPersistTimer = null
|
||||
}
|
||||
const convId = store.state.activeConversationId
|
||||
if (convId) persistDraft(convId, inputText.value)
|
||||
}
|
||||
|
||||
watch(inputText, () => {
|
||||
if (draftPersistTimer) clearTimeout(draftPersistTimer)
|
||||
draftPersistTimer = setTimeout(persistDraftNow, 300)
|
||||
})
|
||||
|
||||
// F2-2:卸载(面板收起/关窗)前冲刷在途防抖,防最后 300ms 键入的草稿丢失
|
||||
onBeforeUnmount(() => {
|
||||
if (draftPersistTimer) persistDraftNow()
|
||||
})
|
||||
|
||||
watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
if (ov) setConvDraft(ov, inputText.value)
|
||||
// immediate:挂载时活跃会话已就位(分离窗/恢复会话直开)也走恢复链,不只在切换时
|
||||
if (ov) {
|
||||
setConvDraft(ov, inputText.value)
|
||||
persistDraft(ov, inputText.value)
|
||||
}
|
||||
inputText.value = getConvDraft(nv)
|
||||
// 内存 Map 无记录时回退落盘稿(重启后 Map 为空;同会话内 Map 恒比落盘新,优先)
|
||||
if (!inputText.value && nv) {
|
||||
const persisted = readDraftsStore()[nv]
|
||||
if (typeof persisted === 'string') inputText.value = persisted
|
||||
}
|
||||
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
|
||||
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
|
||||
pendingMentionSpans.value = []
|
||||
@@ -1205,7 +1298,7 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
mentionStart.value = -1
|
||||
autoResize()
|
||||
})
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
|
||||
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
|
||||
@@ -1259,32 +1352,33 @@ defineExpose({
|
||||
零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。
|
||||
CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ -->
|
||||
<style scoped>
|
||||
/* 2026-10-18 wave2: 输入盒迁移 workbench .inp-box 范式——bg-card 底+0.5px 边框+9px 圆角,
|
||||
focus-within 边框转 info;纵向布局让全宽图片预览行自然换行(替代原 flex-wrap 手法) */
|
||||
.ai-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 6px 8px;
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
|
||||
|
||||
.ai-input-wrap:focus-within {
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
border-color: var(--df-info);
|
||||
}
|
||||
|
||||
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
|
||||
|
||||
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
|
||||
|
||||
/* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式);
|
||||
autosize 高度参数(min/height/max-height)为行为契约,禁动 */
|
||||
.ai-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -1301,6 +1395,19 @@ defineExpose({
|
||||
.ai-input::placeholder {
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */
|
||||
.ai-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* 发送钮(workbench .isend 范式):info→accent 135° 渐变+深色 icon+600 字重;
|
||||
禁用态 opacity .4(禁用判定逻辑不动)。原 --active 实心紫规则并入基类,类名绑定保留无害 */
|
||||
.ai-send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1309,38 +1416,37 @@ defineExpose({
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-raised);
|
||||
color: var(--df-text-dim);
|
||||
background: linear-gradient(135deg, var(--df-info), var(--df-accent));
|
||||
color: color-mix(in srgb, var(--df-bg) 70%, #000);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: filter 0.15s, opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.ai-send-btn:not(:disabled):hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
/* 功能钮统一 28px 高 bg-card 边框钮(workbench 工具行左侧范式),hover 边框亮 */
|
||||
.ai-img-pick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: transparent;
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ai-img-pick-btn:hover {
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card-hover);
|
||||
border-color: var(--df-border-strong);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-send-btn--active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-send-btn--active:hover {
|
||||
background: var(--df-accent-hover);
|
||||
}
|
||||
.ai-send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
@@ -1364,20 +1470,34 @@ defineExpose({
|
||||
.ai-send-btn--retry:hover {
|
||||
background: #d63a3f;
|
||||
}
|
||||
/* 命令提示 chip(workbench .icmd 范式):mono 10px --df-text-dim */
|
||||
.ai-input-hint {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
||||
|
||||
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
||||
.ai-img-warn-chip {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* ── 2026-10-18 wave2: 草稿/mention 弹层对齐 workbench 弹层范式 ──
|
||||
SkillMention/MentionPopover 共用 styles/components.css 的 .ai-skill-* 全局基类(禁碰),
|
||||
经 :deep 在此集中覆写一份(两子组件弹层同源,避免各自 scoped 重复):
|
||||
8px 圆角+浅阴影;选中项 bg-card-hover+左 2px 强调边(inset 阴影实现,零布局位移) */
|
||||
.ai-input-wrap :deep(.ai-skill-popover) {
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
}
|
||||
.ai-input-wrap :deep(.ai-skill-item--active) {
|
||||
background: var(--df-bg-card-hover);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,16 +104,16 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.conflict-resolver {
|
||||
border: 1px solid var(--df-warning, #ffc107);
|
||||
border: 1px solid var(--df-warning);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
background: var(--df-bg-secondary);
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.conflict-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--df-warning, #ffc107);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.conflict-item {
|
||||
border-top: 1px solid var(--df-border);
|
||||
@@ -153,8 +153,8 @@ watch(
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
||||
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
|
||||
.badge--file { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.badge--semantic { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.conflict-file code { font-size: 12px; color: var(--df-text); }
|
||||
.conflict-diffs {
|
||||
display: grid;
|
||||
|
||||
@@ -125,8 +125,10 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* wave2(2026-10-18):旧 token 全量迁 --df-*;统一透明彩底卡范式,accent 系(脉络=系统功能信号)
|
||||
——边框 color-mix 30% + 底 6% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||
.ctx-briefs {
|
||||
border-bottom: 1px solid var(--border, #e0e0e0);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -138,29 +140,36 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
padding: 3px 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
color: var(--df-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-count {
|
||||
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
|
||||
color: var(--accent, #4080ff);
|
||||
border-radius: 8px;
|
||||
background: var(--df-accent-soft);
|
||||
color: var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.ctx-briefs-body {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ctx-briefs-empty {
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--df-text-dim);
|
||||
padding: 6px 2px;
|
||||
}
|
||||
.ctx-briefs-project {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
padding: 5px 2px 2px;
|
||||
}
|
||||
.ctx-briefs-focus-dot {
|
||||
@@ -169,12 +178,12 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
height: 5px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #4080ff);
|
||||
background: var(--df-accent);
|
||||
vertical-align: 2px;
|
||||
}
|
||||
.ctx-briefs-item {
|
||||
padding: 4px 2px;
|
||||
border-top: 1px dashed var(--border, #eee);
|
||||
border-top: 0.5px dashed var(--df-border);
|
||||
}
|
||||
.ctx-briefs-item-head {
|
||||
display: flex;
|
||||
@@ -183,7 +192,7 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
}
|
||||
.ctx-briefs-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #333);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ctx-briefs-item-link {
|
||||
padding: 0;
|
||||
@@ -194,29 +203,31 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-item-link:hover {
|
||||
color: var(--accent, #4080ff);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.ctx-briefs-item-time {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #999);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.ctx-briefs-item-summary {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
margin: 2px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ctx-briefs-bring {
|
||||
border: 1px solid var(--border, #ddd);
|
||||
background: none;
|
||||
color: var(--accent, #4080ff);
|
||||
border-radius: 4px;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
background: transparent;
|
||||
color: var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-bring:hover {
|
||||
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
|
||||
background: var(--df-accent-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,6 +52,18 @@
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
|
||||
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
|
||||
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
|
||||
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
|
||||
<input
|
||||
@@ -70,7 +82,6 @@
|
||||
@dblclick.stop="startRename(conv)"
|
||||
>
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
@@ -145,6 +156,16 @@
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<input
|
||||
v-if="editingConvId === conv.id"
|
||||
:ref="setEditEl"
|
||||
@@ -168,8 +189,6 @@
|
||||
class="ai-conv-item-title-badge"
|
||||
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
@@ -225,6 +244,8 @@
|
||||
@keydown.space.prevent="onRecover(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
|
||||
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
|
||||
<span class="ai-conv-item-title">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span class="ai-conv-recover-badge">⏸</span>
|
||||
@@ -260,9 +281,18 @@
|
||||
@keydown="onItemKeydown($event, conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
@@ -296,6 +326,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
|
||||
<div
|
||||
v-if="convListPaging.hasMore"
|
||||
class="ai-conv-load-more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onLoadMore"
|
||||
@keydown.enter.prevent="onLoadMore"
|
||||
@keydown.space.prevent="onLoadMore"
|
||||
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
|
||||
|
||||
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
|
||||
{{ t('aiChat.emptyConversation') }}
|
||||
</div>
|
||||
@@ -338,7 +379,8 @@ import { convPendingApprovals } from '../../composables/ai/aiShared'
|
||||
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
|
||||
import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary'
|
||||
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
|
||||
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
|
||||
// F4-1: 会话列表分页游标(reactive,绑「加载更多」显隐/loading)+ 续拉/拉满动作
|
||||
import { convOpFailNotice, convListPaging, loadMoreConversations, loadAllConversations } from '../../composables/ai/useAiConversations'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
import type { AiConversationSummary } from '../../api/types'
|
||||
@@ -662,6 +704,28 @@ const searchResults = computed<AiConversationSummary[] | null>(
|
||||
() => store.filteredConversations.value,
|
||||
)
|
||||
|
||||
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
|
||||
|
||||
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
|
||||
function onLoadMore(): void {
|
||||
void loadMoreConversations()
|
||||
}
|
||||
|
||||
// 搜索输入即拉满:搜索过滤(stores.filteredConversations)跑在已加载列表上,未拉满时
|
||||
// 第 51 条起的会话搜不到(硬 50 截断=搜索失灵)——输入非空且还有未拉页时一次拉尽,
|
||||
// 之后搜索自然覆盖全量;已拉满(hasMore=false)零开销不触发。
|
||||
watch(() => store.state.searchQuery, (q) => {
|
||||
if (q.trim() && convListPaging.hasMore && !convListPaging.loading) {
|
||||
void loadAllConversations()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载更多/搜索拉满追加的新会话补拉边界徽标镜像(A10 空洞随列表加深扩大;
|
||||
// hydrate 对已入镜像的会话跳过,常规刷新时零 IPC)
|
||||
watch(() => store.state.conversations.length, () => {
|
||||
void hydrateBoundaryBadges()
|
||||
})
|
||||
|
||||
// ── 侧栏宽度拖拽 ──
|
||||
const resizeState = { startX: 0, startWidth: 0 }
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
@@ -756,7 +820,7 @@ defineExpose({
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-sidebar-resize-handle:hover {
|
||||
background: var(--df-accent, #4f8cff);
|
||||
background: var(--df-accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* 拖拽中全局锁 cursor + 禁选中文本(挂在 body 上,跨元素生效) */
|
||||
@@ -778,13 +842,21 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* UX-06 §3.1: 对话搜索框(紧贴 header 下,样式跟随周围输入控件) */
|
||||
/* UX-06 §3.1: 对话搜索框(wave2 u-agent .sb-search 范式:容器成框,卡底+focus 边框 info) */
|
||||
.ai-conv-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
margin: 8px 10px;
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-conv-search:focus-within {
|
||||
border-color: var(--df-info);
|
||||
}
|
||||
.ai-conv-search-icon {
|
||||
flex: none;
|
||||
@@ -800,8 +872,8 @@ body.ai-sidebar-resizing * {
|
||||
font-size: 12px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
/* 聚焦可见环:容器已随 :focus-within 变 info 边框,键盘环同色补强(scoped 特异性须盖全局兜底) */
|
||||
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-info); outline-offset: 1px; }
|
||||
.ai-conv-search-input::placeholder {
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
@@ -819,12 +891,15 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
padding: 4px 6px; /* u-agent .sb-list 范式;滚动条走 global ::-webkit-scrollbar token 体系 */
|
||||
}
|
||||
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
|
||||
.ai-conv-group-title {
|
||||
padding: 8px 8px 3px;
|
||||
font-size: 11px;
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--df-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
@@ -851,7 +926,10 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-group-count {
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ai-conv-group-empty {
|
||||
padding: 2px 8px 4px 20px;
|
||||
@@ -859,40 +937,47 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
|
||||
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
|
||||
.ai-conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
padding: 8px;
|
||||
border-radius: var(--df-radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-conv-item--active {
|
||||
background: var(--df-sidebar-active);
|
||||
background: var(--df-bg-card-hover);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
|
||||
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
|
||||
.ai-conv-item--switching {
|
||||
background: var(--df-sidebar-active);
|
||||
background: var(--df-bg-card-hover);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-conv-item--archived .ai-conv-item-title {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
|
||||
.ai-conv-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item-title {
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
white-space: nowrap;
|
||||
@@ -902,7 +987,6 @@ body.ai-sidebar-resizing * {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
|
||||
.ai-conv-item-title--flash {
|
||||
@@ -917,34 +1001,35 @@ body.ai-sidebar-resizing * {
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.ai-conv-item-edit {
|
||||
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg, #fff);
|
||||
background: var(--df-bg);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
|
||||
.ai-conv-item-time {
|
||||
font-size: 9px;
|
||||
flex: none;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */
|
||||
.ai-conv-gen-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
animation: ai-conv-gen-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
|
||||
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
|
||||
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
|
||||
.ai-conv-status--danger { background: var(--df-danger); }
|
||||
.ai-conv-status--paused { background: var(--df-warning); }
|
||||
@keyframes ai-conv-gen-pulse {
|
||||
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
@@ -955,7 +1040,7 @@ body.ai-sidebar-resizing * {
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.ai-conv-item--recoverable {
|
||||
@@ -966,7 +1051,7 @@ body.ai-sidebar-resizing * {
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
cursor: help;
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
@@ -975,7 +1060,7 @@ body.ai-sidebar-resizing * {
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
@@ -1032,9 +1117,13 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
|
||||
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
|
||||
.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||
}
|
||||
.ai-conv-item--active.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
||||
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
||||
@@ -1056,8 +1145,8 @@ body.ai-sidebar-resizing * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--df-bg, #fff);
|
||||
border: 1px solid var(--df-border, #e2e2e2);
|
||||
background: var(--df-bg);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
@@ -1088,6 +1177,21 @@ body.ai-sidebar-resizing * {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
|
||||
.ai-conv-load-more {
|
||||
margin: 4px 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ai-conv-load-more:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast,
|
||||
但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */
|
||||
@@ -1102,7 +1206,7 @@ body.ai-sidebar-resizing * {
|
||||
z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */
|
||||
max-width: 85%;
|
||||
overflow-wrap: anywhere;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
border: 0.5px solid var(--df-danger);
|
||||
|
||||
@@ -154,12 +154,12 @@ watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-dir-auth-text {
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-dir-auth-hint {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
word-break: break-all;
|
||||
|
||||
@@ -34,7 +34,7 @@ const examplePrompts = [
|
||||
<div class="ai-empty">
|
||||
<template v-if="!hasProvider">
|
||||
<div class="ai-empty-icon">
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
</div>
|
||||
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
|
||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
|
||||
@@ -46,7 +46,7 @@ const examplePrompts = [
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="ai-empty-icon">
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||
</div>
|
||||
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
|
||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
|
||||
@@ -73,7 +73,8 @@ const examplePrompts = [
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; }
|
||||
/* wave2:空态图标统一 dim 色(经 currentColor 接管 stroke),引导文字 secondary 层级 */
|
||||
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; color: var(--df-text-dim); }
|
||||
.ai-empty-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -81,7 +82,7 @@ const examplePrompts = [
|
||||
}
|
||||
.ai-empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
color: var(--df-text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ const examplePrompts = [
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -74,30 +74,30 @@ function handleSelectOption(opt: string): void {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
|
||||
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(断路器熔断=最高警示级)——
|
||||
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||
.ai-help-required {
|
||||
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);
|
||||
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-help-required-text {
|
||||
font-size: 11.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-help-required-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
word-break: break-all;
|
||||
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
|
||||
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
|
||||
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-family: var(--df-font-mono);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -109,15 +109,15 @@ function handleSelectOption(opt: string): void {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-help-required-btn {
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
color: var(--df-danger);
|
||||
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);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-help-required-btn:hover { filter: brightness(1.1); }
|
||||
.ai-help-required-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
|
||||
</style>
|
||||
|
||||
@@ -84,7 +84,7 @@ const emit = defineEmits<{
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 类型标签小色块(项目/任务区分) */
|
||||
/* 类型标签小色块(项目/任务区分)。2026-10-18: 原底色引用未定义 token 静默失效,修正为 bg-card-hover */
|
||||
.ai-mention-item-type {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 5px;
|
||||
@@ -92,7 +92,7 @@ const emit = defineEmits<{
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
background: var(--df-bg-hover);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-mention-item-type--project {
|
||||
|
||||
@@ -149,29 +149,34 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
||||
align-items: flex-start;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
/* 头像通用 */
|
||||
/* 头像通用 — workbench .av 范式:24px 圆角方块渐变,用户 warning→danger 系,
|
||||
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
|
||||
.ai-msg-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--df-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ai-msg-avatar--user {
|
||||
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
||||
color: var(--df-info, #64b5f6);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--df-warning) 70%, var(--df-danger)), var(--df-danger));
|
||||
color: var(--df-bg);
|
||||
}
|
||||
/* 气泡通用 */
|
||||
/* 气泡通用 — workbench 聊天范式:用户气泡=bg-card 底+0.5px 边框+9px 圆角。
|
||||
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
|
||||
.ai-msg-bubble {
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
padding: 9px 13px;
|
||||
word-break: break-word;
|
||||
max-width: 80%;
|
||||
}
|
||||
.ai-msg-bubble--user {
|
||||
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* chip */
|
||||
@@ -181,13 +186,13 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
margin: 0 2px;
|
||||
background: var(--df-accent-bg, rgba(108,99,255,0.1));
|
||||
color: var(--df-accent, #6c63ff);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
|
||||
.ai-msg-chip--skill {
|
||||
background: var(--df-accent-bg, rgba(108,99,255,0.18));
|
||||
color: var(--df-accent, #6c63ff);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 图片 */
|
||||
|
||||
+144
-165
@@ -45,7 +45,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ──
|
||||
// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中
|
||||
@@ -676,23 +676,28 @@ const _decoCache = new Map<string, string>()
|
||||
const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
// F1-1:复制按钮文案走 i18n(v-html 渲染层拿不到 $t,经参数注入);locale 入缓存键,
|
||||
// 否则切语言后命中旧缓存,按钮文案停留在旧语言。locale 读取同时建立响应式依赖,
|
||||
// 切语言时调用方(computed/渲染函数)重算拿到新语言装饰结果。
|
||||
const cacheKey = locale.value + '\u0000' + raw
|
||||
const hit = _decoCache.get(cacheKey)
|
||||
if (hit !== undefined) {
|
||||
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
|
||||
_decoCache.delete(raw)
|
||||
_decoCache.set(raw, hit)
|
||||
_decoCache.delete(cacheKey)
|
||||
_decoCache.set(cacheKey, hit)
|
||||
return hit
|
||||
}
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
|
||||
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
|
||||
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
|
||||
const copyLabel = t('aiChat.copyCode')
|
||||
const out = raw.replace(
|
||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||
(_m: string, attrs: string, body: string) => {
|
||||
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
|
||||
const langAttr = lang ? ` data-lang="${lang}"` : ''
|
||||
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">${copyLabel}</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||
},
|
||||
)
|
||||
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
|
||||
@@ -700,7 +705,7 @@ function decorateCodeHtml(raw: string): string {
|
||||
const oldest = _decoCache.keys().next().value
|
||||
if (oldest !== undefined) _decoCache.delete(oldest)
|
||||
}
|
||||
_decoCache.set(raw, out)
|
||||
_decoCache.set(cacheKey, out)
|
||||
return out
|
||||
}
|
||||
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
||||
@@ -744,7 +749,7 @@ async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
|
||||
}
|
||||
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '已复制' : '复制失败'
|
||||
btn.textContent = ok ? t('aiChat.copied') : t('aiChat.copyFailed')
|
||||
btn.classList.add(ok ? 'is-ok' : 'is-err')
|
||||
setTimeout(() => {
|
||||
btn.textContent = prev
|
||||
@@ -924,13 +929,12 @@ function collapsedPreviewHtml(msg: AiMessage): string {
|
||||
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
|
||||
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
|
||||
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
|
||||
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
|
||||
// 展开态仍复用既有 aiChat.collapse 键。
|
||||
// 文案走 i18n(F1-1 补键);展开态仍复用既有 aiChat.collapse 键。
|
||||
const CHAR_PER_TOKEN_EST = 2.5
|
||||
function collapsedLongLabel(msg: AiMessage): string {
|
||||
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
|
||||
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
|
||||
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
|
||||
return t('aiChat.foldedSummary', { chars: folded, tokens })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1226,7 +1230,13 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
bucket = []
|
||||
if (msgs.length === 0) return
|
||||
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
|
||||
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
|
||||
// F1-3(10-17 走查):执行中判定补充「桶内存在 running/pending_approval 工具」。
|
||||
// 仅靠「桶末=store 末条」时,生成中注入补充消息会在 store 末尾 push 本地 user 气泡,
|
||||
// 判定瞬间失配 → 执行桶被误判为完成态,降级成默认折叠 trace 卡,正在跑/待审批的
|
||||
// 工具卡跌进折叠体不可见(下一轮 assistant push 才自愈)。桶内工具仍活跃即说明该轮
|
||||
// 未收敛,保持 step 形态内联可见;工具全部收敛后回到末条比对,历史桶不受影响(BUG-261001)。
|
||||
const hasLiveTool = msgs.some(m => (m.toolCalls ?? []).some(tc => tc.status === 'running' || tc.status === 'pending_approval'))
|
||||
const executing = isViewingGenerating.value && (msgs[msgs.length - 1].id === lastStoreId || hasLiveTool)
|
||||
// 961③: 轮号=当前发起计数(与轨迹卡是否产出解耦,单消息桶不吞号)
|
||||
const round = currentRound
|
||||
const built = buildBucket(msgs, round, executing, lastTs)
|
||||
@@ -1566,7 +1576,7 @@ watch(() => store.state.activeConversationId, (newId, oldId) => {
|
||||
onBeforeUnmount(() => {
|
||||
cancelPendingRaf()
|
||||
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
document.removeEventListener('keydown', closeAgentTraceOnEsc)
|
||||
document.removeEventListener('keydown', closeOverlaysOnEsc)
|
||||
})
|
||||
|
||||
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
|
||||
@@ -1575,11 +1585,14 @@ function closeTokenPopoverOnOutsideClick(): void {
|
||||
}
|
||||
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
|
||||
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
|
||||
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
|
||||
// 浮层 Esc 关闭:子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅外点关闭,
|
||||
// 键盘用户开了面板只能点别处)。两者同时开着时逐层关(先浮层后 popover),Esc 不冒泡旁路。
|
||||
function closeOverlaysOnEsc(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Escape') return
|
||||
if (agentPeekVisible.value) closeAgentTrace()
|
||||
else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null
|
||||
}
|
||||
document.addEventListener('keydown', closeAgentTraceOnEsc)
|
||||
document.addEventListener('keydown', closeOverlaysOnEsc)
|
||||
|
||||
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
||||
// 多次调用安全,确保子组件挂载即预热)。
|
||||
@@ -2141,7 +2154,7 @@ defineExpose({
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 15;
|
||||
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||||
}
|
||||
@@ -2161,7 +2174,7 @@ defineExpose({
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-warning, #e8a33d);
|
||||
background: var(--df-warning);
|
||||
animation: ai-btb-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-btb-pulse {
|
||||
@@ -2172,14 +2185,8 @@ defineExpose({
|
||||
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
|
||||
|
||||
/* -- Message Layout -- */
|
||||
.ai-msg-user {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */
|
||||
margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */
|
||||
}
|
||||
/* 用户消息行 .ai-msg-user 随 MessageItem.vue 抽离(自带 scoped,本组件 scoped 不渗透其内部),
|
||||
原重复定义删除(2026-10-18 整洁波,死样式验证:模板零引用+scoped 隔离永不命中) */
|
||||
.ai-msg-ai {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -2197,7 +2204,8 @@ defineExpose({
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* -- Avatar -- */
|
||||
/* -- Avatar -- workbench .av 范式:24px 圆角方块渐变(用户 warning→danger 系 / AI info→accent 系),
|
||||
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
|
||||
.ai-msg-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -2206,41 +2214,44 @@ defineExpose({
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ai-msg-avatar--user {
|
||||
background: var(--df-info-bg);
|
||||
color: var(--df-info);
|
||||
}
|
||||
/* .ai-msg-avatar--user 随 MessageItem.vue 抽离(自带 scoped) */
|
||||
.ai-msg-avatar--ai {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--df-info) 70%, var(--df-accent)), var(--df-accent));
|
||||
color: var(--df-bg);
|
||||
}
|
||||
|
||||
/* -- Bubble -- */
|
||||
/* -- Bubble -- workbench 聊天范式:用户=bg-card 底+0.5px 边框+9px 圆角;AI=无气泡(透明底直接排版)。
|
||||
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
|
||||
.ai-msg-bubble {
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 9px 13px;
|
||||
border-radius: 9px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-msg-bubble--user {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: var(--df-radius-sm);
|
||||
}
|
||||
/* 用户气泡 .ai-msg-bubble--user 随 MessageItem.vue 抽离(自带 scoped,原「同范式兜底」
|
||||
实为 scoped 隔离永不命中的死规则,2026-10-18 整洁波删除) */
|
||||
.ai-msg-bubble--ai {
|
||||
background: var(--df-bg-card);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--df-text);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-bottom-left-radius: var(--df-radius-sm);
|
||||
}
|
||||
/* 错误气泡对齐 workbench .blocked-card 范式:30% 红边框+6% 红底+12px 字;
|
||||
AI 无气泡后本卡自含 padding/边框/圆角(排在 --ai 后覆盖) */
|
||||
.ai-msg-bubble--error {
|
||||
background: var(--df-danger-bg);
|
||||
padding: 10px 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, var(--df-danger) 6%, transparent);
|
||||
color: var(--df-danger);
|
||||
border-color: rgba(240,101,101,0.3);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* 修正 的 display:block 方案——display:block 让 table 失去表格
|
||||
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
|
||||
@@ -2255,8 +2266,8 @@ defineExpose({
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
|
||||
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景,
|
||||
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
|
||||
AI 气泡已透明(workbench 范式):渐变淡出到页面背景 --df-bg,视觉上内容「沉入」页面
|
||||
而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
|
||||
.ai-msg-bubble--collapsed {
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
@@ -2269,7 +2280,7 @@ defineExpose({
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(transparent, var(--df-bg-card));
|
||||
background: linear-gradient(transparent, var(--df-bg));
|
||||
pointer-events: none; /* 让点击穿透到下方展开按钮 */
|
||||
}
|
||||
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
|
||||
@@ -2293,10 +2304,12 @@ defineExpose({
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
|
||||
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
|
||||
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:弱化灰字 / 11px /
|
||||
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
|
||||
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
|
||||
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */
|
||||
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。
|
||||
F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上
|
||||
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。 ── */
|
||||
.ai-msg-json {
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
@@ -2307,7 +2320,7 @@ defineExpose({
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #b0b0b0;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -2325,27 +2338,8 @@ defineExpose({
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
||||
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
||||
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
|
||||
.ai-msg-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
margin: 0 1px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
|
||||
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
|
||||
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
|
||||
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
|
||||
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
|
||||
/* ── mention chip(.ai-msg-chip 族)随 MessageItem.vue 抽离(自带 scoped,含 --skill 变体;
|
||||
此处原 kind 变体因 scoped 隔离永不命中,2026-10-18 整洁波删除) ── */
|
||||
|
||||
/* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
|
||||
per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
|
||||
@@ -2368,10 +2362,13 @@ defineExpose({
|
||||
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */
|
||||
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层);
|
||||
AI 无气泡后本卡自含 padding 保持卡感(普通 AI 消息仍无气泡) ── */
|
||||
.ai-msg-bubble--answer {
|
||||
border-left: 2px solid var(--df-accent);
|
||||
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card));
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-accent) 4%, transparent);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
|
||||
@@ -2415,8 +2412,8 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: var(--df-h-icon-btn);
|
||||
height: var(--df-h-icon-btn);
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
@@ -2426,14 +2423,13 @@ defineExpose({
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-msg-user:hover .ai-copy-btn,
|
||||
.ai-msg-ai:hover .ai-copy-btn--ai {
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-copy-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */
|
||||
/* 图片复制按钮排在文本复制按钮左侧(right 让位 26px 按钮 + 4px 间距) */
|
||||
.ai-copy-btn--image {
|
||||
right: 30px;
|
||||
}
|
||||
@@ -2443,6 +2439,23 @@ defineExpose({
|
||||
.ai-md :deep(.md-code-block) {
|
||||
position: relative;
|
||||
}
|
||||
/* workbench .code 范式:比 --df-bg 更深一档的底 + 边框 + md 圆角 + mono 11px,
|
||||
覆盖全局 ai-md.css 的 .ai-md pre(现有 hljs github-dark 语法高亮色不动)。
|
||||
浅色主题降为 8% 黑:45% 全量在浅底上会压垮正文/高亮对比,违背双主题成立铁律。 */
|
||||
.ai-md :deep(pre) {
|
||||
background: color-mix(in srgb, var(--df-bg), #000 45%);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-md :deep(pre code) {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
[data-theme='light'] .ai-md :deep(pre) {
|
||||
background: color-mix(in srgb, var(--df-bg), #000 8%);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -2468,8 +2481,8 @@ defineExpose({
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-ok) {
|
||||
opacity: 1;
|
||||
color: var(--df-success, #4caf50);
|
||||
border-color: var(--df-success, #4caf50);
|
||||
color: var(--df-success);
|
||||
border-color: var(--df-success);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-err) {
|
||||
opacity: 1;
|
||||
@@ -2519,7 +2532,7 @@ defineExpose({
|
||||
/* Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮,
|
||||
说明为何无重试入口,引导走「去设置」。 */
|
||||
.ai-msg-not-retryable {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
align-self: center;
|
||||
@@ -2528,12 +2541,12 @@ defineExpose({
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
@@ -2565,11 +2578,11 @@ defineExpose({
|
||||
margin-top: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: color-mix(in srgb, var(--df-warning, #ffc107) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
width: fit-content;
|
||||
}
|
||||
.ai-msg-truncated-text {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
@@ -2600,7 +2613,7 @@ defineExpose({
|
||||
padding-top: 6px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -2618,8 +2631,9 @@ defineExpose({
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
/* workbench .round-meta 范式:元数据 hover 变 --df-info */
|
||||
.ai-token-usage-trigger:hover {
|
||||
color: var(--df-text);
|
||||
color: var(--df-info);
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-token-sep {
|
||||
@@ -2627,7 +2641,7 @@ defineExpose({
|
||||
}
|
||||
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
|
||||
.ai-token-est {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
|
||||
@@ -2638,7 +2652,7 @@ defineExpose({
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
min-width: 200px;
|
||||
background: var(--df-bg-card, var(--df-bg));
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
@@ -2678,23 +2692,7 @@ defineExpose({
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 用户气泡内的图片(多模态消息渲染) */
|
||||
.ai-msg-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ai-msg-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: var(--df-radius);
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: var(--df-bg);
|
||||
}
|
||||
/* 用户气泡内图片(.ai-msg-images/.ai-msg-image)随 MessageItem.vue 抽离(自带 scoped) */
|
||||
|
||||
/* ═══ 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
|
||||
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
|
||||
@@ -2773,7 +2771,7 @@ defineExpose({
|
||||
.ai-trace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
@@ -2806,6 +2804,11 @@ defineExpose({
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* workbench .round-meta 范式:轮次/时间/token 小元数据 hover 变 --df-info(折叠交互不动) */
|
||||
.ai-trace-head:hover .ai-trace-agg,
|
||||
.ai-trace-head:hover .ai-trace-tokens {
|
||||
color: var(--df-info);
|
||||
}
|
||||
/* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */
|
||||
.ai-trace-task-chip {
|
||||
flex-shrink: 0;
|
||||
@@ -2826,7 +2829,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
|
||||
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
|
||||
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。
|
||||
workbench .tr 范式:1px 连线用 --df-border。 ── */
|
||||
.ai-trace-body {
|
||||
position: relative;
|
||||
padding: 2px 10px 8px 0;
|
||||
@@ -2838,7 +2842,7 @@ defineExpose({
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
|
||||
background: var(--df-border);
|
||||
}
|
||||
.ai-trace-round {
|
||||
position: relative;
|
||||
@@ -2852,10 +2856,10 @@ defineExpose({
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px 3px 38px;
|
||||
padding: 3px 8px 3px var(--df-icon-gutter);
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
@@ -2872,16 +2876,16 @@ defineExpose({
|
||||
}
|
||||
.ai-think-ic {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--df-accent-bg);
|
||||
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -2896,14 +2900,14 @@ defineExpose({
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.75;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
|
||||
.ai-think-full {
|
||||
margin: 2px 0 6px 38px;
|
||||
margin: 2px 0 6px var(--df-icon-gutter);
|
||||
padding: 8px 12px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
@@ -2916,30 +2920,14 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron,
|
||||
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变 */
|
||||
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变。
|
||||
(.ai-trace-toggle--running/.ai-step-line/.ai-step-text 旧单行摘要骨架已废:step 卡
|
||||
改用 trace-card 同构骨架,模板零引用,2026-10-18 整洁波删除) */
|
||||
.ai-trace-card--running {
|
||||
border-style: solid;
|
||||
border-color: color-mix(in srgb, var(--df-accent) 28%, transparent);
|
||||
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
|
||||
}
|
||||
.ai-trace-toggle--running {
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
|
||||
.ai-step-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-step-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -2948,13 +2936,6 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
animation: ai-step-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-step-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes ai-step-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
@@ -2969,7 +2950,7 @@ defineExpose({
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--df-warning) 8%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
@@ -2978,13 +2959,13 @@ defineExpose({
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
flex-shrink: 0;
|
||||
animation: ai-step-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-retry-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -3018,10 +2999,10 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn {
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--df-warning) 8%, transparent);
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-dot {
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
}
|
||||
.ai-agent-fin--err {
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
@@ -3044,7 +3025,7 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-status {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-status {
|
||||
color: var(--df-danger);
|
||||
@@ -3057,7 +3038,8 @@ defineExpose({
|
||||
.ai-agent-fin-meta {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
|
||||
.ai-agent-fin-rejected {
|
||||
@@ -3068,9 +3050,9 @@ defineExpose({
|
||||
white-space: nowrap;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
|
||||
color: var(--df-warning, #f0c75e);
|
||||
font-size: 10px;
|
||||
background: color-mix(in srgb, var(--df-warning) 16%, transparent);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
|
||||
.ai-agent-fin--notrace {
|
||||
@@ -3080,7 +3062,7 @@ defineExpose({
|
||||
.ai-agent-fin--notrace:hover {
|
||||
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
|
||||
}
|
||||
/* peek 拉取中的加载微标(挂在具体卡右端) */
|
||||
/* peek 拉取中的加载微标(挂在具体卡右端);旋转动画消费 global.css @keyframes df-spin */
|
||||
.ai-agent-fin-spin {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
@@ -3088,10 +3070,7 @@ defineExpose({
|
||||
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
|
||||
border-top-color: var(--df-text-secondary);
|
||||
border-radius: 50%;
|
||||
animation: ai-agent-fin-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ai-agent-fin-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
|
||||
@@ -3112,7 +3091,7 @@ defineExpose({
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border-strong);
|
||||
border-radius: var(--df-radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-agent-peek-head {
|
||||
@@ -3124,7 +3103,7 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-peek-title {
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
}
|
||||
@@ -3169,7 +3148,7 @@ defineExpose({
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
@@ -3193,7 +3172,7 @@ defineExpose({
|
||||
.ai-agent-peek-msg--tool .ai-agent-peek-text,
|
||||
.ai-agent-peek-msg--system .ai-agent-peek-text {
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
|
||||
.ai-agent-peek-head-text {
|
||||
@@ -3214,7 +3193,7 @@ defineExpose({
|
||||
.ai-agent-peek-agent {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-info);
|
||||
background: var(--df-info-bg);
|
||||
border-radius: var(--df-radius-sm);
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
|
||||
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
|
||||
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
@@ -96,6 +96,20 @@ watch(() => props.open, (v) => {
|
||||
if (v) void refresh()
|
||||
})
|
||||
|
||||
// F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。
|
||||
// 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡,
|
||||
// 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果;
|
||||
// IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫)。
|
||||
function onEscClose(e: KeyboardEvent): void {
|
||||
if (!props.open || e.key !== 'Escape') return
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('close')
|
||||
}
|
||||
document.addEventListener('keydown', onEscClose)
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onEscClose))
|
||||
|
||||
// ── 列表展示 ──
|
||||
|
||||
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
|
||||
@@ -230,7 +244,8 @@ async function onCreate(): Promise<void> {
|
||||
.outbox-panel {
|
||||
width: min(420px, 92vw);
|
||||
height: 100%;
|
||||
background: var(--df-bg-card);
|
||||
/* wave2:抽屉底 bg-raised(比 bg-card 抬一级,与遮罩分层) */
|
||||
background: var(--df-bg-raised);
|
||||
border-left: 0.5px solid var(--df-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -259,7 +274,7 @@ async function onCreate(): Promise<void> {
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.outbox-close:hover {
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.outbox-list {
|
||||
@@ -309,8 +324,8 @@ async function onCreate(): Promise<void> {
|
||||
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
}
|
||||
.outbox-badge--failed {
|
||||
color: #e5484d;
|
||||
border-color: color-mix(in srgb, #e5484d 40%, transparent);
|
||||
color: var(--df-danger);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
}
|
||||
.outbox-text {
|
||||
font-size: 12px;
|
||||
@@ -336,7 +351,7 @@ async function onCreate(): Promise<void> {
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.outbox-act:hover:not(:disabled) {
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.outbox-act:disabled {
|
||||
@@ -353,12 +368,12 @@ async function onCreate(): Promise<void> {
|
||||
color: #fff;
|
||||
}
|
||||
.outbox-act--danger:hover:not(:disabled) {
|
||||
color: #e5484d;
|
||||
border-color: color-mix(in srgb, #e5484d 40%, transparent);
|
||||
color: var(--df-danger);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
}
|
||||
.outbox-dt {
|
||||
border: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text);
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
@@ -382,7 +397,7 @@ async function onCreate(): Promise<void> {
|
||||
box-sizing: border-box;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
font-family: var(--df-font-sans);
|
||||
|
||||
@@ -276,7 +276,8 @@ const echoSummary = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
|
||||
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力)
|
||||
wave2(2026-10-18):info 系透明彩底范式(授权边界=信息/配置信号,非警示) */
|
||||
.ai-boundary {
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
@@ -292,7 +293,7 @@ const echoSummary = computed(() => {
|
||||
color: var(--df-text-dim);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius-sm);
|
||||
max-width: 100%;
|
||||
}
|
||||
.ai-boundary-toggle:hover { color: var(--df-text); }
|
||||
@@ -301,26 +302,29 @@ const echoSummary = computed(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
|
||||
.ai-boundary-toggle-label { font-weight: 600; }
|
||||
/* 标题同色(info)12px 600 */
|
||||
.ai-boundary-toggle-label { font-weight: 600; font-size: 12px; color: var(--df-info); }
|
||||
.ai-boundary-echo {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--df-accent);
|
||||
color: var(--df-info);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-boundary-body {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--df-info) 6%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-boundary-locked {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-boundary-loadfail {
|
||||
@@ -328,7 +332,7 @@ const echoSummary = computed(() => {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-boundary-row {
|
||||
@@ -350,7 +354,7 @@ const echoSummary = computed(() => {
|
||||
color: var(--df-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-boundary-unlimited input { accent-color: var(--df-accent); margin: 0; }
|
||||
.ai-boundary-unlimited input { accent-color: var(--df-info); margin: 0; }
|
||||
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
|
||||
.ai-boundary-input {
|
||||
flex: 1;
|
||||
@@ -363,7 +367,7 @@ const echoSummary = computed(() => {
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
outline: none;
|
||||
}
|
||||
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
|
||||
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-info) 40%, transparent); }
|
||||
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ai-boundary-input--num { flex: 0 0 90px; }
|
||||
.ai-boundary-mini-btn {
|
||||
@@ -413,10 +417,11 @@ const echoSummary = computed(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* 一次授权钮:info 系透明底描边钮(对应色操作钮范式) */
|
||||
.ai-boundary-confirm {
|
||||
border: none;
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-info) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-info) 10%, transparent);
|
||||
color: var(--df-info);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
@@ -424,7 +429,7 @@ const echoSummary = computed(() => {
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
|
||||
.ai-boundary-confirm:hover:not(:disabled) { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
|
||||
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ai-boundary-clear {
|
||||
border: 0.5px solid var(--df-border);
|
||||
@@ -442,7 +447,7 @@ const echoSummary = computed(() => {
|
||||
.ai-boundary-field.is-error span { color: var(--df-danger); }
|
||||
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
|
||||
.ai-boundary-dirty {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,15 +99,16 @@ async function handleStopArchive(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
|
||||
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(非正常终局停靠=需用户拍板信号)——
|
||||
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||
.ai-task-parked {
|
||||
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);
|
||||
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-task-parked-head {
|
||||
@@ -115,37 +116,39 @@ async function handleStopArchive(): Promise<void> {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* stop_kind 标签:透明 danger 描边 chip;fatal 连续失败重入耗尽=实底警示级最高 */
|
||||
.ai-task-parked-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
background: color-mix(in srgb, var(--df-danger) 12%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
color: var(--df-danger);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
.ai-task-parked-tag--fatal {
|
||||
background: var(--df-danger);
|
||||
border-color: var(--df-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-task-parked-title {
|
||||
font-size: 11.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-task-parked-reason {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
|
||||
.ai-task-parked-preserved {
|
||||
font-size: 10.5px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.8;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-task-parked-actions {
|
||||
display: flex;
|
||||
@@ -155,17 +158,17 @@ async function handleStopArchive(): Promise<void> {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-task-parked-btn {
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
color: var(--df-danger);
|
||||
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);
|
||||
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-task-parked-btn:hover { filter: brightness(1.1); }
|
||||
.ai-task-parked-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
|
||||
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ai-task-parked-btn--muted {
|
||||
background: transparent;
|
||||
|
||||
+109
-78
@@ -14,7 +14,7 @@
|
||||
<button
|
||||
v-if="pendingApprovalCount > 0"
|
||||
type="button"
|
||||
class="ai-approval-badge"
|
||||
class="ai-approval-badge df-badge df-badge--danger"
|
||||
:title="$t('aiChat.pendingApprovalHint')"
|
||||
@click="emit('scroll-to-pending')"
|
||||
>
|
||||
@@ -24,7 +24,7 @@
|
||||
<button
|
||||
v-if="unattendedStatus?.breaker_tripped"
|
||||
type="button"
|
||||
class="ai-unattended-badge ai-unattended-badge--tripped"
|
||||
class="ai-unattended-badge df-badge df-badge--danger"
|
||||
:title="$t('aiChat.unattendedResetHint')"
|
||||
@click="resetUnattendedBreaker"
|
||||
>
|
||||
@@ -33,7 +33,7 @@
|
||||
<button
|
||||
v-else-if="unattendedStatus?.enabled"
|
||||
type="button"
|
||||
class="ai-unattended-badge"
|
||||
class="ai-unattended-badge df-badge df-badge--accent"
|
||||
:title="$t('aiChat.unattendedBadgeHint')"
|
||||
@click="jumpToUnattendedSettings"
|
||||
>
|
||||
@@ -50,7 +50,7 @@
|
||||
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
|
||||
@click="cycleProvider"
|
||||
>
|
||||
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
|
||||
<span class="df-dot provider-dot" :class="'provider-dot--' + providerStatus"></span>
|
||||
<span class="provider-name">{{ activeProviderName }}</span>
|
||||
</button>
|
||||
<div class="ai-provider-bar ai-provider-empty" v-else>
|
||||
@@ -182,7 +182,7 @@
|
||||
</div>
|
||||
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) -->
|
||||
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
|
||||
<button type="button" class="ai-running-tasks-badge ai-tool-badge" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
|
||||
<button type="button" class="ai-running-tasks-badge ai-tool-badge df-badge df-badge--info" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
|
||||
<span class="ai-running-tasks-pulse"></span>
|
||||
{{ runningTasks.length }}
|
||||
</button>
|
||||
@@ -236,6 +236,8 @@
|
||||
<span class="ai-history-text">{{ h.text }}</span>
|
||||
<span class="ai-history-time">{{ h.time }}</span>
|
||||
</div>
|
||||
<!-- F6-1:目标消息在折叠段内未渲染,面板不收 + 行内提示 -->
|
||||
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -251,6 +253,8 @@
|
||||
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
|
||||
<p class="ai-summary-preview">{{ s.text }}</p>
|
||||
</div>
|
||||
<!-- F6-1:摘要项同享定位未命中提示(压缩段折叠时 system 消息同样不在 DOM) -->
|
||||
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -413,6 +417,7 @@ onMounted(() => { void refreshUnattended() })
|
||||
onBeforeUnmount(() => {
|
||||
unattendedDisposed = true
|
||||
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
|
||||
if (locateMissTimer) { clearTimeout(locateMissTimer); locateMissTimer = null }
|
||||
})
|
||||
|
||||
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
|
||||
@@ -768,8 +773,23 @@ const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
|
||||
const historyExpanded = ref(false)
|
||||
|
||||
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
||||
// F6-1(走查-信息UX 面六):目标消息在折叠段(已归档/已压缩)内时,折叠态不渲染
|
||||
// [data-msg-id] 元素,querySelector 必空。原实现先收面板再查 DOM,未命中即静默无反应;
|
||||
// 现改为面板保持展开 + 行内提示引导展开分隔条。展开折叠段的状态在 MessageList 内持有,
|
||||
// 本组件不可达,提示是最小闭环(不跨组件撬状态)。
|
||||
const locateMissHint = ref('')
|
||||
let locateMissTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showLocateMiss(): void {
|
||||
locateMissHint.value = t('aiChat.historyLocateInSegment')
|
||||
if (locateMissTimer) clearTimeout(locateMissTimer)
|
||||
locateMissTimer = setTimeout(() => {
|
||||
locateMissHint.value = ''
|
||||
locateMissTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function scrollToMessage(msgId: string) {
|
||||
historyExpanded.value = false
|
||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
|
||||
// msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
|
||||
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
|
||||
@@ -787,8 +807,13 @@ function scrollToMessage(msgId: string) {
|
||||
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
|
||||
}
|
||||
if (el) {
|
||||
locateMissHint.value = ''
|
||||
historyExpanded.value = false
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return
|
||||
}
|
||||
// 未命中:不收面板,提示替代静默
|
||||
showLocateMiss()
|
||||
}
|
||||
|
||||
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
|
||||
@@ -824,6 +849,7 @@ const contextExpanded = ref(false)
|
||||
|
||||
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
|
||||
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
|
||||
locateMissHint.value = ''
|
||||
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
|
||||
const willOpen = !refs[panel].value
|
||||
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
|
||||
@@ -965,56 +991,37 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡;div→button 后归零默认样式) */
|
||||
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
|
||||
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
|
||||
.ai-approval-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
/* review ③:div→button 后 UA outset 边框需显式归零(appearance:none 不含 border) */
|
||||
border: none;
|
||||
}
|
||||
.ai-approval-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-approval-badge:focus-visible {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline: 1.5px solid var(--df-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 挂机引擎态徽章(开启=accent 弱底;熔断=红底红字,点击复位) */
|
||||
/* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
|
||||
.ai-unattended-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
border: none;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-unattended-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-unattended-badge--tripped {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
border: 0.5px solid var(--df-danger);
|
||||
}
|
||||
|
||||
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
|
||||
.ai-model-picker {
|
||||
@@ -1053,25 +1060,28 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)── */
|
||||
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)
|
||||
2026-10-18 wave2:统一 workbench .hd-btn 范式——bg-card 底+边框+md 圆角+hover 边框亮 ── */
|
||||
.ai-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-btn-icon:hover {
|
||||
background: var(--df-bg-card);
|
||||
border-color: var(--df-border-strong);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-btn-icon--active {
|
||||
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
@@ -1080,7 +1090,8 @@ onBeforeUnmount(() => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-btn-icon:disabled:hover {
|
||||
background: transparent;
|
||||
border-color: var(--df-border);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
|
||||
@@ -1094,7 +1105,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1113,16 +1124,13 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-hover, rgba(0,0,0,0.06)); color: var(--df-text); }
|
||||
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
|
||||
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
/* Transition:淡入 + 下滑 */
|
||||
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
|
||||
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
/* 压缩中 spinner 图标旋转动画(@keyframes ai-btn-spin 在父级 AiChat.vue 全局定义,
|
||||
scoped 不重命名 @keyframes,此处按名引用) */
|
||||
.ai-btn-spinner {
|
||||
animation: ai-btn-spin 0.9s linear infinite;
|
||||
}
|
||||
/* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
|
||||
原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
|
||||
/* 底部工具区(标题左 + 工具右) */
|
||||
.ai-bottom-tools {
|
||||
display: flex;
|
||||
@@ -1192,6 +1200,10 @@ onBeforeUnmount(() => {
|
||||
.ai-task-progress--warn .ai-task-progress-dot {
|
||||
animation-duration: 3.2s;
|
||||
}
|
||||
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
|
||||
.ai-task-progress--warn .ai-task-progress-dot {
|
||||
background: var(--df-warning);
|
||||
}
|
||||
.ai-task-progress-stage {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
@@ -1200,39 +1212,45 @@ onBeforeUnmount(() => {
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
|
||||
}
|
||||
/* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
|
||||
/* ═══ Provider Bar (header 中间区)
|
||||
2026-10-18 wave2:对齐 workbench .session-status pill 范式——bg-card 底+边框+8px 圆角+12px 字,
|
||||
状态点迁 .df-dot(7px 尺寸由全局公共类提供);div→button 后归零默认样式 ═══ */
|
||||
.ai-provider-bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 4px 12px;
|
||||
transition: border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-provider-bar--switchable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-provider-bar--switchable:hover {
|
||||
color: var(--df-text-primary);
|
||||
border-color: var(--df-border-strong);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-provider-bar--switchable:focus-visible {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline: 1.5px solid var(--df-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--df-radius-md);
|
||||
}
|
||||
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */
|
||||
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; }
|
||||
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError);
|
||||
尺寸/圆角走全局 .df-dot,此处只管色 */
|
||||
.provider-dot { flex-shrink: 0; transition: background .2s; background: var(--df-text-dim); }
|
||||
.provider-dot--ok { background: var(--df-success); }
|
||||
.provider-dot--error { background: var(--df-danger); }
|
||||
.provider-name {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-dim);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-provider-empty {
|
||||
background: var(--df-danger-bg);
|
||||
@@ -1265,7 +1283,7 @@ onBeforeUnmount(() => {
|
||||
appearance: none;
|
||||
}
|
||||
.ai-tool-badge:focus-visible {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline: 1.5px solid var(--df-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
|
||||
@@ -1291,11 +1309,17 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* wave2:后台会话徽章迁 .df-badge--info(透明彩底 info 系);scoped 需显式覆写压过 .ai-tool-badge 灰底 */
|
||||
.ai-running-tasks-badge.df-badge--info {
|
||||
background: var(--df-info-bg);
|
||||
color: var(--df-info);
|
||||
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||
}
|
||||
.ai-running-tasks-pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success, #34c77b);
|
||||
background: var(--df-success);
|
||||
animation: ai-running-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-running-pulse {
|
||||
@@ -1311,9 +1335,9 @@ onBeforeUnmount(() => {
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -1331,14 +1355,14 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-running-task-item:hover {
|
||||
background: var(--df-hover, rgba(128,128,128,0.08));
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-running-task-dot {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success, #34c77b);
|
||||
background: var(--df-success);
|
||||
}
|
||||
.ai-running-task-title {
|
||||
flex: 1;
|
||||
@@ -1349,7 +1373,7 @@ onBeforeUnmount(() => {
|
||||
.ai-running-task-round {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary, #888);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
|
||||
.ai-running-tasks-sep {
|
||||
@@ -1358,7 +1382,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--df-border);
|
||||
}
|
||||
.ai-running-task-dot--parked {
|
||||
background: var(--df-warning, #e6a23c);
|
||||
background: var(--df-warning);
|
||||
}
|
||||
/* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
|
||||
.ai-running-task-item--parked {
|
||||
@@ -1385,21 +1409,21 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-running-task-main:hover {
|
||||
background: var(--df-hover, rgba(128,128,128,0.08));
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-running-task-recover {
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--df-warning, #e6a23c);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--df-warning);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--df-warning, #e6a23c);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-running-task-recover:hover {
|
||||
background: var(--df-warning, #e6a23c);
|
||||
background: var(--df-warning);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-history-inline-list {
|
||||
@@ -1411,9 +1435,9 @@ onBeforeUnmount(() => {
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
margin-top: 2px;
|
||||
@@ -1454,6 +1478,13 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
|
||||
.ai-locate-miss {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
|
||||
/* ═══ 上下文摘要(底部) ═══ */
|
||||
.ai-summary-inline {
|
||||
@@ -1470,9 +1501,9 @@ onBeforeUnmount(() => {
|
||||
max-width: 420px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 6px 0;
|
||||
margin-top: 2px;
|
||||
@@ -1518,9 +1549,9 @@ onBeforeUnmount(() => {
|
||||
z-index: 100;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -1571,9 +1602,9 @@ onBeforeUnmount(() => {
|
||||
z-index: 100;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
background: var(--df-bg, #fff);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
margin-top: 2px;
|
||||
@@ -1601,7 +1632,7 @@ onBeforeUnmount(() => {
|
||||
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
|
||||
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
|
||||
.ai-goal-remove:hover {
|
||||
color: var(--df-danger, #e5484d);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.provider-hint {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
<template>
|
||||
<!-- ═══ 会话列表体(拆自 ConversationSidebar.vue C4a,零行为变更) ═══ -->
|
||||
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
|
||||
<template v-if="searchResults">
|
||||
<div
|
||||
v-for="conv in searchResults"
|
||||
:key="'s-' + conv.id"
|
||||
class="ai-conv-item"
|
||||
:class="{
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--switching': conv.id === pendingSwitchId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
'ai-conv-item--pinned': conv.pinned,
|
||||
}"
|
||||
tabindex="0"
|
||||
@click="onSelectSearchResult(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
|
||||
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
|
||||
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
|
||||
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
|
||||
<input
|
||||
v-if="editingConvId === conv.id"
|
||||
:ref="setEditEl"
|
||||
v-model="editText"
|
||||
class="ai-conv-item-edit"
|
||||
@keydown="onRenameKeydown($event, conv.id)"
|
||||
@blur="commitRename(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ai-conv-item-title"
|
||||
:title="conv.title || t('aiChat.doubleClickToRename')"
|
||||
@dblclick.stop="startRename(conv)"
|
||||
>
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
|
||||
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
{{ t('aiChat.renameConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
||||
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
||||
{{ t('aiChat.searchEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 非搜索态:原时间分组(活跃) + 归档分组 -->
|
||||
<template v-else>
|
||||
<!-- 活跃对话:按 今天/昨天/更早 分组 -->
|
||||
<template v-for="group in groupedActive" :key="'g-' + group.key">
|
||||
<div
|
||||
class="ai-conv-group-title ai-conv-group-title--fold"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!store.state.foldedGroups[group.key]"
|
||||
@click="store.toggleGroupFold(group.key)"
|
||||
@keydown.enter.prevent="store.toggleGroupFold(group.key)"
|
||||
@keydown.space.prevent="store.toggleGroupFold(group.key)"
|
||||
>
|
||||
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }">▸</span>
|
||||
{{ group.label }}
|
||||
<span class="ai-conv-group-count">({{ group.items.length }})</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.key === 'today' && !group.items.length"
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
class="ai-conv-group-empty"
|
||||
>{{ t('aiChat.groupEmpty') }}</div>
|
||||
<div
|
||||
v-for="conv in group.items"
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
:key="conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
tabindex="0"
|
||||
@click="beginSwitch(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<input
|
||||
v-if="editingConvId === conv.id"
|
||||
:ref="setEditEl"
|
||||
v-model="editText"
|
||||
class="ai-conv-item-edit"
|
||||
@keydown="onRenameKeydown($event, conv.id)"
|
||||
@blur="commitRename(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ai-conv-item-title"
|
||||
:class="{ 'ai-conv-item-title--flash': titleFlash && conv.id === store.state.activeConversationId }"
|
||||
@dblclick.stop="startRename(conv)"
|
||||
:title="conv.title || t('aiChat.doubleClickToRename')"
|
||||
>
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
|
||||
<svg aria-hidden="true"
|
||||
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
|
||||
class="ai-conv-item-title-badge"
|
||||
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
|
||||
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
{{ t('aiChat.renameConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
||||
{{ t('aiChat.archive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- G5 可恢复分组(loop_state='parked',断点续跑闭环读侧;置于活跃分组后、归档前) -->
|
||||
<template v-if="recoverable.length">
|
||||
<div class="ai-conv-group-title">
|
||||
<span class="ai-conv-recover-dot" aria-hidden="true"></span>
|
||||
{{ t('aiChat.recoverableCount', { n: recoverable.length }) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in recoverable"
|
||||
:key="'r-' + conv.id"
|
||||
class="ai-conv-item ai-conv-item--recoverable"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
||||
tabindex="0"
|
||||
:title="t('aiChat.recoverableHint')"
|
||||
@click="onRecover(conv.id)"
|
||||
@keydown.enter.prevent="onRecover(conv.id)"
|
||||
@keydown.space.prevent="onRecover(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
|
||||
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
|
||||
<span class="ai-conv-item-title">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span class="ai-conv-recover-badge">⏸</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 归档分组(可折叠) -->
|
||||
<template v-if="archivedConvs.length">
|
||||
<div
|
||||
class="ai-conv-group-title ai-conv-group-title--fold"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!store.state.archivedCollapsed"
|
||||
@click="store.toggleArchivedFold()"
|
||||
@keydown.enter.prevent="store.toggleArchivedFold()"
|
||||
@keydown.space.prevent="store.toggleArchivedFold()"
|
||||
>
|
||||
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.archivedCollapsed }">▸</span>
|
||||
{{ t('aiChat.archivedCount', { n: archivedConvs.length }) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in archivedConvs"
|
||||
v-show="!store.state.archivedCollapsed"
|
||||
:key="'a-' + conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item ai-conv-item--archived"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
tabindex="0"
|
||||
@click="beginSwitch(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
|
||||
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
|
||||
{{ t('aiChat.unarchive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
|
||||
<div
|
||||
v-if="convListPaging.hasMore"
|
||||
class="ai-conv-load-more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onLoadMore"
|
||||
@keydown.enter.prevent="onLoadMore"
|
||||
@keydown.space.prevent="onLoadMore"
|
||||
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
|
||||
|
||||
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
|
||||
{{ t('aiChat.emptyConversation') }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 会话列表体 — 拆自 ConversationSidebar.vue(C4a 大文件拆分,零行为变更)。
|
||||
*
|
||||
* 职责:侧栏列表两态渲染(搜索态平铺 / 时间分组+可恢复+归档)及项级自治状态:
|
||||
* 重命名(editingConvId/editText)、更多操作菜单(actionsOpenId + document 级外部点击/Esc 关闭)、
|
||||
* 切换过渡(pendingSwitchId)、项 DOM ref 收集(M3 展开折叠组后 scrollIntoView)、
|
||||
* 标题生成淡入(titleFlash)、时间分组归桶(groupedActive/archivedConvs)。
|
||||
*
|
||||
* 跨文件依赖(策略同侧栏第一批:store 共享走 useAiStore,不 props 传 store):
|
||||
* - props.pendingCountOf:V1 待审批计数(主文件 computed + 2.5s 轮询兜底锚定,函数透传保响应式);
|
||||
* - props.recoverable:G5 可恢复清单(主文件 onMounted 拉取,侧栏开合不重复 IPC),恢复动作 emit('recover');
|
||||
* - emit('confirm-delete'):删除需父级 ConfirmDialog,侧栏主文件继续上抛 AiChat。
|
||||
* 边界徽标只读消费 getConvBoundary(reactive Map,模板内读取可追踪),直接 import 同源 composable。
|
||||
*/
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../../stores/ai'
|
||||
import { getConvBoundary } from '../../../composables/ai/useTaskBoundary'
|
||||
import { convListPaging, loadMoreConversations } from '../../../composables/ai/useAiConversations'
|
||||
import { formatRelative } from '../../../utils/time'
|
||||
import type { AiConversationSummary } from '../../../api/types'
|
||||
|
||||
/** G5 可恢复会话条目(主文件数据源透传,字段口径同 aiApi.listRecoverable) */
|
||||
interface RecoverableConv { id: string; title: string | null; model: string | null; updated_at: string }
|
||||
|
||||
defineProps<{
|
||||
/** V1 跨会话待审批计数(主文件 computed:三响应式源锚定 + 轮询兜底;函数透传,模板调用可追踪) */
|
||||
pendingCountOf: (convId: string) => number
|
||||
/** G5 可恢复分组数据(主文件拉取与防重,本组件纯渲染 + emit 恢复动作) */
|
||||
recoverable: RecoverableConv[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 删除对话(需二次确认),上抛侧栏主文件继续交 AiChat 弹 ConfirmDialog */
|
||||
(e: 'confirm-delete', id: string): void
|
||||
/** G5 恢复 parked 会话,主文件持 recoveringId 防重 + toast 反馈 */
|
||||
(e: 'recover', id: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAiStore()
|
||||
|
||||
// ── 标题生成淡入(UX-2025-20 §7.4):监听当前对话标题从空→非空触发 ──
|
||||
const titleFlash = ref(false)
|
||||
let _titleFlashTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const activeConv = computed(() =>
|
||||
store.state.conversations.find(c => c.id === store.state.activeConversationId) || null,
|
||||
)
|
||||
watch(
|
||||
() => activeConv.value?.title,
|
||||
(newTitle, oldTitle) => {
|
||||
// 标题从空→非空才触发淡入,其他变化不闪
|
||||
if (!newTitle || oldTitle) return
|
||||
titleFlash.value = true
|
||||
if (_titleFlashTimer) clearTimeout(_titleFlashTimer)
|
||||
_titleFlashTimer = setTimeout(() => { titleFlash.value = false }, 320)
|
||||
},
|
||||
)
|
||||
|
||||
// ── 对话项 inline 重命名 ──
|
||||
const editingConvId = ref<string | null>(null)
|
||||
const editText = ref('')
|
||||
let editEl: HTMLInputElement | null = null
|
||||
function setEditEl(el: Element | { $el?: Element } | null): void {
|
||||
editEl = (el as HTMLInputElement) || null
|
||||
}
|
||||
|
||||
function startRename(conv: { id: string; title: string | null }) {
|
||||
editingConvId.value = conv.id
|
||||
editText.value = conv.title || ''
|
||||
nextTick(() => {
|
||||
editEl?.focus()
|
||||
editEl?.select()
|
||||
})
|
||||
}
|
||||
|
||||
async function commitRename(id: string) {
|
||||
const title = editText.value.trim()
|
||||
if (title && editingConvId.value === id) {
|
||||
await store.renameConversation(id, title)
|
||||
}
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
|
||||
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
|
||||
function onRenameKeydown(e: KeyboardEvent, convId: string) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename(convId)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}
|
||||
|
||||
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
|
||||
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
|
||||
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
|
||||
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
|
||||
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
beginSwitch(convId)
|
||||
} else if (renamable && e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
const conv = store.state.conversations.find(c => c.id === convId)
|
||||
if (conv) startRename(conv)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
||||
const actionsOpenId = ref<string | null>(null)
|
||||
|
||||
function toggleActionsMenu(id: string) {
|
||||
actionsOpenId.value = actionsOpenId.value === id ? null : id
|
||||
}
|
||||
|
||||
function closeActionsMenu() {
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// 点击外部关闭更多操作菜单
|
||||
function onActionsOutsideClick(e: MouseEvent) {
|
||||
if (!actionsOpenId.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('.ai-conv-item-more')) return
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// UX走查 P2-6: 操作菜单原仅外部点击可关,键盘用户开菜单后无 Esc 逃逸;IME 守卫防组词取消误关
|
||||
function onActionsEscClose(e: KeyboardEvent) {
|
||||
if (!actionsOpenId.value) return
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Escape') actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
|
||||
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
|
||||
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
|
||||
const pendingSwitchId = ref<string | null>(null)
|
||||
function beginSwitch(id: string) {
|
||||
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
|
||||
if (id === store.state.activeConversationId) {
|
||||
void store.switchConversation(id)
|
||||
return
|
||||
}
|
||||
pendingSwitchId.value = id
|
||||
void store.switchConversation(id).finally(() => {
|
||||
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
|
||||
if (pendingSwitchId.value === id) pendingSwitchId.value = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
|
||||
beginSwitch(id)
|
||||
store.state.searchQuery = ''
|
||||
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||
void nextTick(() => ensureActiveVisible())
|
||||
}
|
||||
|
||||
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
|
||||
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
|
||||
const itemEls = new Map<string, Element>()
|
||||
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
|
||||
if (!el) {
|
||||
itemEls.delete(id)
|
||||
return
|
||||
}
|
||||
// 组件实例取 $el,但本项是普通 div(el 即 Element)
|
||||
itemEls.set(id, el as Element)
|
||||
}
|
||||
|
||||
/**
|
||||
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
|
||||
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
|
||||
*/
|
||||
function ensureActiveVisible(): void {
|
||||
const id = store.state.activeConversationId
|
||||
if (!id) return
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
if (!conv) return
|
||||
// 先按归档态/时间桶决定应展开哪个组折叠态
|
||||
if (conv.archived) {
|
||||
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
|
||||
} else {
|
||||
const bucket = timeBucket(conv.updated_at)
|
||||
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
|
||||
}
|
||||
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
|
||||
void nextTick(() => {
|
||||
const el = itemEls.get(id)
|
||||
if (el) {
|
||||
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
|
||||
function hasBoundary(convId: string): boolean {
|
||||
return getConvBoundary(convId) !== null
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||
today: t('aiChat.today'),
|
||||
yesterday: t('aiChat.yesterday'),
|
||||
earlier: t('aiChat.earlier'),
|
||||
}))
|
||||
|
||||
function timeBucket(ts: string): 'today' | 'yesterday' | 'earlier' {
|
||||
const t = parseInt(ts, 10)
|
||||
if (!Number.isFinite(t)) return 'earlier'
|
||||
const d = new Date(t)
|
||||
const now = new Date()
|
||||
const sameDay = d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate()
|
||||
if (sameDay) return 'today'
|
||||
const yest = new Date(now)
|
||||
yest.setDate(now.getDate() - 1)
|
||||
if (d.getFullYear() === yest.getFullYear()
|
||||
&& d.getMonth() === yest.getMonth()
|
||||
&& d.getDate() === yest.getDate()) return 'yesterday'
|
||||
return 'earlier'
|
||||
}
|
||||
|
||||
const groupedActive = computed(() => {
|
||||
const map = new Map<string, AiConversationSummary[]>()
|
||||
for (const c of store.state.conversations) {
|
||||
if (c.archived) continue
|
||||
const b = timeBucket(c.updated_at)
|
||||
if (!map.has(b)) map.set(b, [])
|
||||
map.get(b)!.push(c)
|
||||
}
|
||||
for (const items of map.values()) {
|
||||
items.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
||||
|| Number(b.updated_at) - Number(a.updated_at))
|
||||
}
|
||||
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
|
||||
for (const key of ['today', 'yesterday', 'earlier']) {
|
||||
const items = map.get(key) || []
|
||||
if (key === 'today' || items.length) groups.push({ key, label: bucketLabels.value[key], items })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const archivedConvs = computed(() =>
|
||||
store.state.conversations
|
||||
.filter(c => c.archived)
|
||||
.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
||||
|| Number(b.updated_at) - Number(a.updated_at)),
|
||||
)
|
||||
|
||||
const searchResults = computed<AiConversationSummary[] | null>(
|
||||
() => store.filteredConversations.value,
|
||||
)
|
||||
|
||||
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
|
||||
|
||||
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
|
||||
function onLoadMore(): void {
|
||||
void loadMoreConversations()
|
||||
}
|
||||
|
||||
/** G5 恢复动作透传主文件(主文件持 recoveringId 防重与 toast 反馈),模板零改动 */
|
||||
function onRecover(id: string) {
|
||||
emit('recover', id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 点击外部关闭更多操作菜单
|
||||
document.addEventListener('click', onActionsOutsideClick)
|
||||
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
|
||||
document.addEventListener('keydown', onActionsEscClose)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onActionsOutsideClick)
|
||||
document.removeEventListener('keydown', onActionsEscClose)
|
||||
// 释放标题淡入计时器
|
||||
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ═══ 会话列表体 scoped 样式(拆自 ConversationSidebar.vue,零视觉变更;列表滚动容器
|
||||
.ai-conv-sidebar/.ai-conv-list 等壳层样式留主文件,本组件只持项/分组/菜单/徽标层) ═══ -->
|
||||
<style scoped>
|
||||
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
|
||||
.ai-conv-group-title {
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--df-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-conv-group-title--fold {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.ai-conv-group-title--fold:hover {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-conv-group-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.15s;
|
||||
font-size: 9px;
|
||||
}
|
||||
.ai-conv-group-arrow.is-folded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.ai-conv-group-arrow:not(.is-folded) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.ai-conv-group-count {
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ai-conv-group-empty {
|
||||
padding: 2px 8px 4px 20px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
|
||||
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
|
||||
.ai-conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: var(--df-radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-conv-item--active {
|
||||
background: var(--df-bg-card-hover);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
|
||||
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
|
||||
.ai-conv-item--switching {
|
||||
background: var(--df-bg-card-hover);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-conv-item--archived .ai-conv-item-title {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
|
||||
.ai-conv-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
|
||||
.ai-conv-item-title--flash {
|
||||
animation: ai-title-flash 0.3s var(--df-ease);
|
||||
}
|
||||
.ai-conv-item-title-badge {
|
||||
flex-shrink: 0;
|
||||
color: var(--df-success);
|
||||
}
|
||||
@keyframes ai-title-flash {
|
||||
from { opacity: 0; transform: translateX(-3px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.ai-conv-item-edit {
|
||||
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
|
||||
.ai-conv-item-time {
|
||||
flex: none;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
|
||||
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
|
||||
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
|
||||
.ai-conv-status--danger { background: var(--df-danger); }
|
||||
.ai-conv-status--paused { background: var(--df-warning); }
|
||||
@keyframes ai-conv-gen-pulse {
|
||||
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
/* G5 可恢复分组(parked 会话,amber 系提示「需注意/可继续」语义;⏸ 徽标+分组标题小圆点) */
|
||||
.ai-conv-recover-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
background: var(--df-warning);
|
||||
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.ai-conv-item--recoverable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-conv-recover-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--df-warning);
|
||||
cursor: help;
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
.ai-conv-approve-badge { flex-shrink: 0;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */
|
||||
.ai-conv-boundary-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
|
||||
color: var(--df-accent);
|
||||
cursor: help;
|
||||
}
|
||||
.ai-conv-item-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-item:hover .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
/* UX走查 P2-6: 键盘 Tab 进会话项(项自身 tabindex=0,或焦点在项内按钮/输入框)时同样浮出
|
||||
操作按钮——原仅 :hover 渲染,键盘用户永远够不到「...」菜单触发点 */
|
||||
.ai-conv-item:focus-within .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
|
||||
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
.ai-conv-item-act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-item-act:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
|
||||
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
|
||||
.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||
}
|
||||
.ai-conv-item--active.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
||||
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
||||
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
|
||||
.ai-conv-item-more {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-actions-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
z-index: 20;
|
||||
min-width: 104px;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--df-bg);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.ai-conv-actions-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-conv-actions-opt:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
}
|
||||
.ai-conv-actions-opt--danger:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-conv-empty {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
|
||||
.ai-conv-load-more {
|
||||
margin: 4px 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ai-conv-load-more:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user