优化: 聊天体验批与Web规范合规

This commit is contained in:
lxy
2026-10-05 23:41:32 +08:00
parent 9e81182b4f
commit 28e6acbf3f
41 changed files with 1518 additions and 570 deletions
+83 -8
View File
@@ -53,7 +53,18 @@
:aria-label="$t('aiChat.pickImage')"
@click="onPickImage"
>
<svg 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>
<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>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
@@ -63,7 +74,7 @@
:title="$t('aiChat.stopping')"
disabled
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<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'"
@@ -71,7 +82,7 @@
:title="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg 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>
<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'"
@@ -79,7 +90,7 @@
:title="$t('aiChat.stopGenerating')"
@click="store.stopChat"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<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
@@ -88,7 +99,7 @@
:disabled="!canSend"
@click="handleSend"
>
<svg 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>
<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>
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
@@ -205,6 +216,66 @@ async function addImageFile(file: File): Promise<void> {
}
}
/** I9(2026-10-05):「📄 文件」按钮——选文本/代码文件读内容,以代码块形式插入输入框草稿。
* 病灶:附件通道仅图片,文本/代码文件无入口(粘贴/拖入均仅认 image/)。用户要给 AI 看一段
* 代码/配置只能手动开文件复制。本方案走纯前端:读文件内容插入草稿,复用既有文本发送链,
* 零协议/后端改动。多选逐个插入;单文件 256KB/5000 行上限(超出截断并提示,防撑爆上下文)。
* 扩展名白名单取常见文本/代码/配置格式,二进制格式(png/pdf/zip 等)不在名单直接拒绝。 */
const TEXT_FILE_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'csv', 'tsv',
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'vue', 'svelte', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'svg',
'py', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'h', 'cpp', 'hpp', 'cc', 'hh', 'cs', 'swift', 'm', 'mm',
'rb', 'php', 'pl', 'pm', 'lua', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'r',
'dart', 'gradle', 'cmake', 'dockerfile', 'makefile', 'gitignore', 'editorconfig', 'properties', 'proto', 'graphql', 'prisma',
]
const MAX_TEXT_FILE_BYTES = 256 * 1024
const MAX_TEXT_FILE_LINES = 5000
async function onPickTextFile(): Promise<void> {
const selected = await open({
multiple: true,
title: t('aiChat.pickFileTitle'),
filters: [{ name: t('aiChat.textFileFilter'), extensions: TEXT_FILE_EXTENSIONS }],
})
if (!selected) return
const files = Array.isArray(selected) ? selected : [selected]
for (const path of files) await insertTextFileDraft(path)
}
/** 读单个文本文件 → 截断(超限提示)→ 以 fenced code block 插入草稿(光标回到块尾) */
async function insertTextFileDraft(path: string): Promise<void> {
try {
// 复用图片链的 blobFromPath(convertFileSrc→fetch),项目未装 plugin-fs 零新依赖
const fileObj = await blobFromPath(path)
if (!fileObj) throw new Error('blobFromPath returned null')
const content = await fileObj.text()
const fileName = path.split(/[\\/]/).pop() || path
let text = content
let truncated = false
if (content.length > MAX_TEXT_FILE_BYTES) {
text = content.slice(0, MAX_TEXT_FILE_BYTES)
truncated = true
}
const lines = text.split('\n')
if (lines.length > MAX_TEXT_FILE_LINES) {
text = lines.slice(0, MAX_TEXT_FILE_LINES).join('\n')
truncated = true
}
// 文件名后缀作语言提示(无扩展名/特殊名不给语言,裸 ``` 也够用)
const ext = fileName.includes('.') ? (fileName.split('.').pop() || '').toLowerCase() : ''
const lang = TEXT_FILE_EXTENSIONS.includes(ext) ? ext : ''
const block = '\n\n```' + lang + ' <!-- ' + fileName + ' -->\n' + text + (truncated ? '\n… ' + t('aiChat.fileTooLargeTruncated') : '') + '\n```\n'
inputText.value += block
await nextTick()
inputEl.value?.focus()
const el = inputEl.value
if (el) el.selectionStart = el.selectionEnd = el.value.length
} catch (e) {
// 用户取消读取/二进制解码失败/权限拒绝:提示后继续(多选时不阻断后续文件)
emit('error', { msg: t('aiChat.fileReadFailed'), type: 'warning' })
console.error('[AI] 文本文件读取失败:', path, e)
}
}
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
async function onPickImage(): Promise<void> {
const selected = await open({
@@ -727,7 +798,7 @@ function selectMention(item: MentionItem) {
el.focus()
el.setSelectionRange(pos, pos)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}
@@ -830,11 +901,15 @@ function handleKeydown(e: KeyboardEvent) {
}
}
/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致,
* 用户手工拉伸到 200px 后任意按键触发 autoResize 被压回 120px,违反 resize:vertical 预期) */
const INPUT_MAX_HEIGHT = 200
function autoResize() {
const el = inputEl.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
}
@@ -1022,7 +1097,7 @@ function fillDraft(text: string, skill: SkillInfo | null = null): void {
const len = el.value.length
el.setSelectionRange(len, len)
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
}
})
}