Skip to content

Commit de9ae56

Browse files
nieaoclaude
andcommitted
feat(upload): BottomAIBar 支持多文件上传 + 多次追加
之前 file input 没 multiple, 一次只能选一个文件 (用户图 44 反馈)。 现在: - input multiple, 一次可选多个 .md/.txt/.markdown - 多次点击附件按钮可分批追加 - 每个附件独立 chip 显示, 单独可移除, ≥2 个时显示"清空 N" - 提交时多附件依次拼到 prompt, 总长度 24000 字硬上限防爆 LLM Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3c2cd27 commit de9ae56

1 file changed

Lines changed: 97 additions & 50 deletions

File tree

src/pages/panels/BottomAIBar.jsx

Lines changed: 97 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function BottomAIBar({ showLeftPanel = true, showRightPanel = true, rightPanelWi
3838
const [mode, setMode] = useState('meta')
3939
const [submitting, setSubmitting] = useState(false)
4040
const [lastNodeId, setLastNodeId] = useState(null)
41-
const [importedFile, setImportedFile] = useState(null) // { name, text }MD/TXT 文件预览
41+
const [importedFiles, setImportedFiles] = useState([]) // [{ name, text, fullSize }]多文件支持
4242
const fileInputRef = useRef(null)
4343
const textareaRef = useRef(null)
4444

@@ -55,43 +55,74 @@ function BottomAIBar({ showLeftPanel = true, showRightPanel = true, rightPanelWi
5555
const askAndCreateHtmlNode = useCanvasStore((s) => s.askAndCreateHtmlNode)
5656
const askAndStartMetaProject = useCanvasStore((s) => s.askAndStartMetaProject)
5757

58-
// 文件选择 → 解析 → 预览到 input 提示
58+
// 文件选择 → 解析 → 预览到 input 提示 (支持多选 + 多次追加)
5959
const handleFilePick = async (e) => {
60-
const file = e.target.files?.[0]
61-
if (!file) return
62-
try {
63-
const parsed = await parseFile(file)
64-
const text = String(parsed.content || '').trim()
65-
if (!text) throw new Error('文件为空或解析失败')
66-
// 截断到 8000 字符防止 prompt 过大 (LLM context 限制)
67-
const truncated = text.length > 8000 ? text.slice(0, 8000) + '\n\n...(已截断, 原文 ' + text.length + ' 字)' : text
68-
setImportedFile({ name: file.name, text: truncated, fullSize: text.length })
69-
// 自动填到 input 作为引导
70-
setInput(`基于附件《${file.name}》内容做元认知拆解 + 推导`)
71-
} catch (err) {
72-
alert(`文件解析失败: ${err?.message || err}`)
73-
} finally {
74-
if (fileInputRef.current) fileInputRef.current.value = ''
60+
const files = Array.from(e.target.files || [])
61+
if (files.length === 0) return
62+
const failed = []
63+
const parsedList = []
64+
for (const file of files) {
65+
try {
66+
const parsed = await parseFile(file)
67+
const text = String(parsed.content || '').trim()
68+
if (!text) {
69+
failed.push(`${file.name}: 文件为空`)
70+
continue
71+
}
72+
// 单文件截断 8000 字 (多文件时还会再做总量裁剪)
73+
const truncated = text.length > 8000 ? text.slice(0, 8000) + '\n\n...(已截断, 原文 ' + text.length + ' 字)' : text
74+
parsedList.push({ name: file.name, text: truncated, fullSize: text.length })
75+
} catch (err) {
76+
failed.push(`${file.name}: ${err?.message || err}`)
77+
}
7578
}
79+
if (parsedList.length > 0) {
80+
// append 到现有列表 (用户可分批选)
81+
setImportedFiles((prev) => [...prev, ...parsedList])
82+
// 仅在 input 为空时填默认引导
83+
setInput((cur) => {
84+
if (cur.trim()) return cur
85+
const allNames = [...importedFiles, ...parsedList].map((f) => `《${f.name}》`).join('、')
86+
return `基于附件 ${allNames} 内容做元认知拆解 + 推导`
87+
})
88+
}
89+
if (failed.length > 0) {
90+
alert(`部分文件解析失败:\n${failed.join('\n')}`)
91+
}
92+
if (fileInputRef.current) fileInputRef.current.value = ''
7693
}
7794

78-
const clearImportedFile = () => setImportedFile(null)
95+
const clearImportedFile = (idx) => {
96+
if (typeof idx === 'number') setImportedFiles((prev) => prev.filter((_, i) => i !== idx))
97+
else setImportedFiles([])
98+
}
7999

80100
const canSubmit = input.trim().length > 0 && !submitting
81101

82102
const handleSubmit = async () => {
83103
if (!canSubmit) return
84104
let text = input.trim()
85-
// 如果有附件 MD/TXT, 把内容拼到 prompt 后面给 LLM 当作上下文
86-
if (importedFile?.text) {
87-
text = `${text}\n\n=== 附件: ${importedFile.name} ===\n${importedFile.text}`
105+
// 多附件依次拼到 prompt 后, 总长度上限 24000 字 (LLM 上下文兜底)
106+
if (importedFiles.length > 0) {
107+
let attachBlock = ''
108+
let acc = 0
109+
const HARD_CAP = 24000
110+
for (const f of importedFiles) {
111+
const piece = `\n\n=== 附件: ${f.name} ===\n${f.text}`
112+
if (acc + piece.length > HARD_CAP) {
113+
attachBlock += `\n\n=== 附件: ${f.name} ===\n[多附件总长超 ${HARD_CAP} 字, 此项已跳过, 原 ${f.fullSize} 字]`
114+
} else {
115+
attachBlock += piece
116+
acc += piece.length
117+
}
118+
}
119+
text = `${text}${attachBlock}`
88120
}
89121
setSubmitting(true)
90122
try {
91123
let nodeId
92124
if (mode === 'meta') {
93125
// 元认知 = 6-stage 多节点 (上下文/拆解/agent涌现/拓扑/执行/决策反思)
94-
// 画布上看真实拆分 + agent 涌现, 整个流程在 store 里串行揭示
95126
nodeId = await askAndStartMetaProject(text)
96127
} else if (mode === 'oneshot') {
97128
// 极简 HTML = 一次性单节点 5 维度 HTML
@@ -102,7 +133,7 @@ function BottomAIBar({ showLeftPanel = true, showRightPanel = true, rightPanelWi
102133
}
103134
setLastNodeId(nodeId)
104135
setInput('')
105-
setImportedFile(null) // 提交后清空附件
136+
setImportedFiles([]) // 提交后清空附件
106137
} catch (err) {
107138
console.error('[BottomAIBar] submit failed:', err)
108139
alert(`提交失败: ${err?.message || err}`)
@@ -179,39 +210,55 @@ function BottomAIBar({ showLeftPanel = true, showRightPanel = true, rightPanelWi
179210
</div>
180211
</div>
181212

182-
{/* === 已选附件预览 === */}
183-
{importedFile && (
184-
<div
185-
className="flex items-center gap-2 mx-3 mb-1 px-3 py-1.5 text-[10px] rounded-md"
186-
style={{
187-
background: 'var(--warm-bg, #f5f0eb)',
188-
border: '1px solid var(--warm-light, #e8d5c0)',
189-
color: 'var(--gray-700, #555)',
190-
}}
191-
>
192-
<svg className="w-3 h-3" style={{ color: 'var(--warm, #c8a882)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
193-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
194-
</svg>
195-
<span style={{ color: 'var(--warm, #c8a882)', fontWeight: 500 }}>{importedFile.name}</span>
196-
<span>· {importedFile.text.length}{importedFile.fullSize > importedFile.text.length ? ` (原 ${importedFile.fullSize} 字, 已截断)` : ''}</span>
197-
<span className="flex-1" />
198-
<button
199-
type="button"
200-
onClick={clearImportedFile}
201-
className="px-1.5 py-0.5 rounded hover:bg-red-50 transition-colors"
202-
style={{ color: 'var(--gray-500, #888)' }}
203-
title="移除附件"
204-
>
205-
206-
</button>
213+
{/* === 已选附件预览 (多文件) === */}
214+
{importedFiles.length > 0 && (
215+
<div className="mx-3 mb-1 flex flex-wrap gap-1.5">
216+
{importedFiles.map((f, idx) => (
217+
<div
218+
key={`${f.name}-${idx}`}
219+
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-[10px] rounded-md"
220+
style={{
221+
background: 'var(--warm-bg, #f5f0eb)',
222+
border: '1px solid var(--warm-light, #e8d5c0)',
223+
color: 'var(--gray-700, #555)',
224+
}}
225+
>
226+
<svg className="w-3 h-3" style={{ color: 'var(--warm, #c8a882)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
227+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
228+
</svg>
229+
<span style={{ color: 'var(--warm, #c8a882)', fontWeight: 500, maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={f.name}>{f.name}</span>
230+
<span style={{ opacity: 0.7 }}>· {f.text.length}{f.fullSize > f.text.length ? ' (截)' : ''}</span>
231+
<button
232+
type="button"
233+
onClick={() => clearImportedFile(idx)}
234+
className="px-1 rounded hover:bg-red-50 transition-colors"
235+
style={{ color: 'var(--gray-500, #888)' }}
236+
title="移除此附件"
237+
>
238+
239+
</button>
240+
</div>
241+
))}
242+
{importedFiles.length > 1 && (
243+
<button
244+
type="button"
245+
onClick={() => clearImportedFile()}
246+
className="text-[10px] px-2 py-1 rounded transition-colors"
247+
style={{ color: 'var(--gray-500, #888)', border: '1px solid var(--gray-100, #e8e8e8)' }}
248+
title="全部移除"
249+
>
250+
清空 {importedFiles.length}
251+
</button>
252+
)}
207253
</div>
208254
)}
209255

210-
{/* 隐藏的文件 input */}
256+
{/* 隐藏的文件 input — multiple 允许选多个 */}
211257
<input
212258
ref={fileInputRef}
213259
type="file"
214260
accept=".md,.txt,.markdown"
261+
multiple
215262
style={{ display: 'none' }}
216263
onChange={handleFilePick}
217264
/>
@@ -227,7 +274,7 @@ function BottomAIBar({ showLeftPanel = true, showRightPanel = true, rightPanelWi
227274
style={{
228275
border: '1px solid var(--gray-100, #e8e8e8)',
229276
background: 'var(--white, #fff)',
230-
color: importedFile ? 'var(--warm, #c8a882)' : 'var(--gray-500, #888)',
277+
color: importedFiles.length > 0 ? 'var(--warm, #c8a882)' : 'var(--gray-500, #888)',
231278
cursor: submitting ? 'not-allowed' : 'pointer',
232279
}}
233280
title="导入 MD / TXT 文件 → 喂给元认知作上下文"

0 commit comments

Comments
 (0)