优化: 聊天体验批与Web规范合规
This commit is contained in:
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user