@@ -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