feat: 支持上传文本/代码文件给 Ollama 分析

- utils.js: 新增 fileToText() 读取文件文本、detectLanguage() 识别编程语言
- index.html: 新增文件上传按钮(📄)和文件预览容器
- input-area.js: 完整文件上传流程(校验/读取/预览/消息构造/发送)
- style.css: 文件 chip 预览样式

支持 50+ 种文本/代码扩展名,文件内容以代码块格式嵌入消息。
单文件限制 500KB,支持多文件同时上传。
This commit is contained in:
Metona
2026-04-04 21:08:21 +08:00
parent 2fa274e1b1
commit 7f31034073
4 changed files with 255 additions and 26 deletions
+64
View File
@@ -806,6 +806,70 @@ body::before {
justify-content: center;
}
/* ── 文件预览区域 ── */
.file-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
}
.file-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
background: rgba(0, 245, 212, 0.06);
border: 1px solid rgba(0, 245, 212, 0.18);
font-size: 12px;
line-height: 1;
transition: var(--transition);
animation: fadeInUp 0.2s ease;
}
.file-chip:hover {
border-color: rgba(0, 245, 212, 0.35);
background: rgba(0, 245, 212, 0.1);
}
.file-icon {
font-size: 14px;
flex-shrink: 0;
}
.file-name {
font-weight: 600;
color: var(--text-primary);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: var(--text-muted);
flex-shrink: 0;
}
.remove-file {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 0 2px;
border-radius: 4px;
transition: var(--transition);
flex-shrink: 0;
}
.remove-file:hover {
color: var(--accent-red);
background: rgba(239, 68, 68, 0.1);
}
.input-row {
display: flex;
align-items: flex-end;
+12 -1
View File
@@ -95,14 +95,25 @@
<!-- ═══════════════ 输入区域 ═══════════════ -->
<div class="input-area" id="inputArea">
<div class="image-preview" id="imagePreview" style="display:none;"></div>
<div class="file-preview" id="filePreview" style="display:none;"></div>
<div class="input-row">
<button class="icon-btn attach-btn" id="btnAttach" title="上传图片">
<button class="icon-btn attach-btn" id="btnAttachImg" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<button class="icon-btn attach-btn" id="btnAttachFile" title="上传文件(文本/代码)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</button>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none;">
<input type="file" id="textFileInput" accept=".txt,.md,.log,.csv,.py,.pyw,.js,.mjs,.cjs,.ts,.tsx,.jsx,.java,.c,.h,.cpp,.cc,.hpp,.go,.rs,.rb,.php,.sh,.bash,.zsh,.sql,.html,.htm,.css,.json,.jsonl,.xml,.svg,.yaml,.yml,.toml,.ini,.cfg,.conf,.swift,.kt,.kts,.scala,.lua,.r,.m,.cs,.fs,.ex,.exs,.erl,.hs,.clj,.lisp,.diff,.patch,Dockerfile,Makefile,.rst,.tsv,.vb,.pl,.mm,.pyi,.pyw,.fsharp,.makefile,.dockerfile" multiple style="display:none;">
<textarea class="chat-input" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
<label class="think-toggle" id="thinkToggleWrap" title="Think 模式未可用">
<input type="checkbox" id="toggleThink" disabled>
+138 -25
View File
@@ -1,10 +1,10 @@
/**
* InputArea - 输入区域组件
* 包含文本输入、图片上传、发送逻辑
* 包含文本输入、图片上传、文件上传、发送逻辑
*/
import { state, KEYS } from '../state.js';
import { fileToBase64, debounce, truncate } from '../utils.js';
import { fileToBase64, fileToText, detectLanguage, debounce, truncate, escapeHtml, formatSize } from '../utils.js';
import { getSelectedModel, isThinkEnabled } from './model-bar.js';
import {
renderMessages, appendAssistantPlaceholder, appendSystemMessage,
@@ -13,16 +13,43 @@ import {
import { showToast } from './toast.js';
import { checkConnection } from './header.js';
let chatInputEl, btnSendEl, fileInputEl, imagePreviewEl;
let chatInputEl, btnSendEl, fileInputEl, textFileInputEl, imagePreviewEl, filePreviewEl;
let pendingImages = [];
let pendingFiles = [];
/** 常见文本/代码文件扩展名集合(用于校验) */
const TEXT_EXTENSIONS = new Set([
'txt','md','markdown','rst','log','csv','tsv',
'py','pyw','pyi','js','mjs','cjs','ts','tsx','jsx',
'java','c','h','cpp','cc','cxx','hpp','go','rs','rb','php',
'sh','bash','zsh','fish','sql','html','htm','css',
'json','jsonl','xml','svg','yaml','yml','toml','ini','cfg','conf',
'swift','kt','kts','scala','lua','r','m','mm',
'cs','fs','vb','ex','exs','erl','hs','clj','lisp',
'diff','patch','pl','dockerfile','makefile'
]);
/** 最大文件大小 500KB */
const MAX_FILE_SIZE = 500 * 1024;
/** 判断是否为文本/代码文件 */
function isTextFile(file) {
// 无扩展名的特殊文件名
const name = file.name.toLowerCase();
if (name === 'dockerfile' || name === 'makefile') return true;
const ext = name.split('.').pop();
return TEXT_EXTENSIONS.has(ext);
}
export function initInputArea() {
chatInputEl = document.querySelector('#chatInput');
btnSendEl = document.querySelector('#btnSend');
fileInputEl = document.querySelector('#fileInput');
textFileInputEl = document.querySelector('#textFileInput');
imagePreviewEl = document.querySelector('#imagePreview');
filePreviewEl = document.querySelector('#filePreview');
// 发送
// 发送 / 停止
btnSendEl.addEventListener('click', () => {
if (state.get(KEYS.IS_STREAMING)) {
stopGeneration();
@@ -41,9 +68,16 @@ export function initInputArea() {
chatInputEl.addEventListener('input', autoResizeTextarea);
// 图片上传
document.querySelector('#btnAttach').addEventListener('click', () => fileInputEl.click());
document.querySelector('#btnAttachImg').addEventListener('click', () => fileInputEl.click());
fileInputEl.addEventListener('change', (e) => {
handleFileSelect(e.target.files);
handleImageSelect(e.target.files);
e.target.value = '';
});
// 文件上传(文本/代码)
document.querySelector('#btnAttachFile').addEventListener('click', () => textFileInputEl.click());
textFileInputEl.addEventListener('change', (e) => {
handleTextFileSelect(e.target.files);
e.target.value = '';
});
@@ -55,6 +89,15 @@ export function initInputArea() {
renderImagePreviews();
}
});
// 删除文件预览
filePreviewEl.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-file')) {
const idx = parseInt(e.target.dataset.index);
pendingFiles.splice(idx, 1);
renderFilePreviews();
}
});
}
function autoResizeTextarea() {
@@ -62,16 +105,16 @@ function autoResizeTextarea() {
chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px';
}
async function handleFileSelect(files) {
const maxSize = 20 * 1024 * 1024;
// ── 图片处理 ──
async function handleImageSelect(files) {
const maxSize = 20 * 1024 * 1024;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
if (file.size > maxSize) {
appendSystemMessage(`⚠️ 图片 ${file.name} 超过 20MB 限制`);
continue;
}
try {
const base64 = await fileToBase64(file);
pendingImages.push({ name: file.name, base64 });
@@ -91,15 +134,66 @@ function renderImagePreviews() {
imagePreviewEl.style.display = '';
imagePreviewEl.innerHTML = pendingImages.map((img, i) => `
<div class="preview-thumb">
<img src="data:image/png;base64,${img.base64}" alt="${img.name}">
<img src="data:image/png;base64,${img.base64}" alt="${escapeHtml(img.name)}">
<button class="remove-img" data-index="${i}">×</button>
</div>
`).join('');
}
// ── 文件处理(文本/代码) ──
async function handleTextFileSelect(files) {
for (const file of files) {
if (!isTextFile(file)) {
appendSystemMessage(`⚠️ ${file.name} 不是支持的文本/代码文件`);
continue;
}
if (file.size > MAX_FILE_SIZE) {
appendSystemMessage(`⚠️ ${file.name} 超过 500KB 限制(${formatSize(file.size)}`);
continue;
}
// 重复检测
if (pendingFiles.some(f => f.name === file.name && f.size === file.size)) {
appendSystemMessage(`${file.name} 已添加`);
continue;
}
try {
const content = await fileToText(file);
pendingFiles.push({
name: file.name,
content,
language: detectLanguage(file.name),
size: file.size
});
renderFilePreviews();
} catch (err) {
console.error('[InputArea] 文件读取失败:', err);
appendSystemMessage(`❌ 读取 ${file.name} 失败`);
}
}
}
function renderFilePreviews() {
if (pendingFiles.length === 0) {
filePreviewEl.style.display = 'none';
filePreviewEl.innerHTML = '';
return;
}
filePreviewEl.style.display = '';
filePreviewEl.innerHTML = pendingFiles.map((f, i) => `
<div class="file-chip">
<span class="file-icon">📄</span>
<span class="file-name">${escapeHtml(f.name)}</span>
<span class="file-size">${formatSize(f.size)}</span>
<button class="remove-file" data-index="${i}" title="移除">×</button>
</div>
`).join('');
}
// ── UI 状态 ──
function updateSendButton(streaming) {
if (streaming) {
// 流式时显示停止按钮
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>`;
@@ -107,7 +201,6 @@ function updateSendButton(streaming) {
btnSendEl.classList.remove('disabled');
btnSendEl.title = '停止生成';
} else {
// 恢复发送按钮
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>`;
@@ -118,7 +211,7 @@ function updateSendButton(streaming) {
}
/**
* 停止生成 - 真正中止 Ollama 接口请求
* 停止生成
*/
function stopGeneration() {
const abortController = state.get(KEYS.ABORT_CONTROLLER);
@@ -132,7 +225,7 @@ function stopGeneration() {
*/
export async function sendMessage() {
const text = chatInputEl.value.trim();
if (!text && pendingImages.length === 0) return;
if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
if (state.get(KEYS.IS_STREAMING)) return;
const model = getSelectedModel();
@@ -144,44 +237,69 @@ export async function sendMessage() {
const currentSession = state.get(KEYS.CURRENT_SESSION);
if (!currentSession) return;
// 构造用户消息
const now = Date.now();
const msgsToAdd = [];
// ── 构造用户消息内容 ──
// 场景1:有图片(Ollama 需要单独的 images 消息)
if (pendingImages.length > 0) {
msgsToAdd.push({
role: 'user',
content: '',
content: pendingImages.length === 1
? `[上传了图片: ${pendingImages[0].name}]`
: `[上传了 ${pendingImages.length} 张图片]`,
images: pendingImages.map(img => img.base64),
timestamp: now
});
}
if (text) {
// 场景2:有文本输入 + 文件,或者只有文件
if (text || pendingFiles.length > 0) {
let content = text || '';
// 把文件内容格式化为代码块拼入消息
if (pendingFiles.length > 0) {
const fileParts = pendingFiles.map(f => {
const lang = f.language || '';
return `📄 **${f.name}**\n\n\`\`\`${lang}\n${f.content}\n\`\`\``;
});
if (content) {
content += '\n\n---\n' + fileParts.join('\n\n---\n');
} else {
content = `请分析以下 ${pendingFiles.length > 1 ? pendingFiles.length + ' 个' : ''}文件:\n\n${fileParts.join('\n\n---\n')}`;
}
}
msgsToAdd.push({
role: 'user',
content: text,
content,
timestamp: now
});
}
// 更新会话标题
if (currentSession.messages.length === 0) {
currentSession.title = truncate(text || '[图片消息]', 30);
const titleText = text
|| (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]');
currentSession.title = truncate(titleText, 30);
currentSession.model = model;
}
msgsToAdd.forEach(m => currentSession.messages.push(m));
renderMessages();
// 立即保存(防止新建会话或刷新时丢失用户消息)
// 立即保存
await saveCurrentSession();
// 清空输入
chatInputEl.value = '';
pendingImages = [];
pendingFiles = [];
imagePreviewEl.style.display = 'none';
imagePreviewEl.innerHTML = '';
filePreviewEl.style.display = 'none';
filePreviewEl.innerHTML = '';
autoResizeTextarea();
// 助手占位
@@ -199,7 +317,6 @@ export async function sendMessage() {
const systemPrompt = state.get(KEYS.SYSTEM_PROMPT);
const numCtx = state.get(KEYS.NUM_CTX, 24576);
// 流式回复状态变量(声明在 try 外,catch 中也需要访问)
let assistantContent = '';
let thinkContent = '';
let finalStats = null;
@@ -256,7 +373,6 @@ export async function sendMessage() {
} catch (err) {
console.error('[InputArea] 流式聊天错误:', err);
// 处理用户主动停止(AbortError
if (err.name === 'AbortError') {
const placeholder = document.querySelector('#messagesContainer .message.assistant:last-child');
const loadingDots = placeholder?.querySelector('.loading-dots');
@@ -266,7 +382,6 @@ export async function sendMessage() {
const contentDiv = placeholder?.querySelector('.msg-content');
// 保存部分消息(直接用 assistantContent,不从 DOM 读)
const partialMsg = {
role: 'assistant',
content: assistantContent,
@@ -277,7 +392,6 @@ export async function sendMessage() {
};
currentSession.messages.push(partialMsg);
// 更新 DOM
if (contentDiv) {
let html = '';
if (assistantContent) {
@@ -296,7 +410,6 @@ export async function sendMessage() {
return;
}
// 其他错误
const placeholder = document.querySelector('#messagesContainer .message.assistant:last-child');
const loadingDots = placeholder?.querySelector('.loading-dots');
const loadingText = placeholder?.querySelector('.loading-text');
+41
View File
@@ -71,3 +71,44 @@ export function fileToBase64(file) {
reader.readAsDataURL(file);
});
}
/** File → 文本内容 */
export function fileToText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
/** 根据文件名检测语言标识(用于 Markdown 代码块) */
export function detectLanguage(filename) {
const ext = filename.split('.').pop().toLowerCase();
const map = {
py: 'python', pyw: 'python', pyi: 'python',
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
java: 'java', class: 'java',
c: 'c', h: 'c',
cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
go: 'go', rs: 'rust', rb: 'ruby', php: 'php',
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
sql: 'sql', html: 'html', htm: 'html', css: 'css',
json: 'json', jsonl: 'json',
xml: 'xml', svg: 'svg',
yaml: 'yaml', yml: 'yaml', toml: 'toml',
ini: 'ini', cfg: 'ini', conf: 'ini',
md: 'markdown', markdown: 'markdown', rst: 'rst',
txt: '', csv: 'csv', tsv: 'csv', log: '',
swift: 'swift', kt: 'kotlin', kts: 'kotlin',
scala: 'scala', lua: 'lua', pl: 'perl', r: 'r',
m: 'matlab', mm: 'objectivec',
cs: 'csharp', fs: 'fsharp', vb: 'vbnet',
ex: 'elixir', exs: 'elixir', erl: 'erlang',
hs: 'haskell', clj: 'clojure', lisp: 'lisp',
dockerfile: 'dockerfile', makefile: 'makefile',
diff: 'diff', patch: 'diff',
};
return map[ext] ?? ext;
}