@@ -1653,10 +1653,14 @@ sendBtn.addEventListener('click', (e) => {
16531653 if ( sess && rt ( sess ) . busy ) { cancelPrompt ( ) ; return ; } // 运行中:发送键是录制键 → 纯停止
16541654 submitInput ( ) ;
16551655} ) ;
1656- inputEl . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey && ! e . isComposing && e . keyCode !== 229 ) { e . preventDefault ( ) ; submitInput ( ) ; } } ) ;
1656+ inputEl . addEventListener ( 'keydown' , ( e ) => {
1657+ if ( atomicPlaceholderDelete ( e ) ) return ; // #2 紧邻占位符时整块删除
1658+ if ( e . key === 'Enter' && ! e . shiftKey && ! e . isComposing && e . keyCode !== 229 ) { e . preventDefault ( ) ; submitInput ( ) ; }
1659+ } ) ;
16571660inputEl . addEventListener ( 'input' , ( ) => {
16581661 inputEl . style . height = 'auto' ;
16591662 inputEl . style . height = Math . min ( inputEl . scrollHeight , 200 ) + 'px' ;
1663+ reconcilePendingFiles ( ) ; // #1 占位符被删/改坏 → 同步清理附件
16601664} ) ;
16611665function showSystem ( text ) {
16621666 const sess = activeSess ( ) ; if ( ! sess ) return ;
@@ -2067,7 +2071,7 @@ function removePlaceholderFromComposer(file) {
20672071function expandFilePlaceholders ( text ) {
20682072 return text . replace ( / \[ ( I m a g e | F i l e ) # ( \d + ) \] / g, ( m , kind , n ) => {
20692073 const f = state . pendingFiles . find ( x => x . sid === Number ( n ) ) ;
2070- return ( f && f . path ) ? f . path : m ;
2074+ return ( f && f . path ) ? f . path : '' ; // #3 悬空占位符(无对应文件)→ 删掉,不把垃圾发给 agent
20712075 } ) ;
20722076}
20732077
@@ -2081,6 +2085,59 @@ function collectUsedFiles(text) {
20812085 return used ;
20822086}
20832087
2088+ // ── 附件占位符健壮性 ─────────────────────────────────────────────
2089+ // 统一移除一个待发附件:出列 + (可选)抹占位符 + 重绘 + 删 bridge 上的文件
2090+ function removePendingFile ( sid , { stripPlaceholder = false } = { } ) {
2091+ const idx = state . pendingFiles . findIndex ( f => f . sid === sid ) ;
2092+ if ( idx < 0 ) return ;
2093+ const removed = state . pendingFiles . splice ( idx , 1 ) [ 0 ] ;
2094+ if ( stripPlaceholder ) removePlaceholderFromComposer ( removed ) ;
2095+ renderThumbStrip ( ) ;
2096+ if ( removed . path ) {
2097+ fetch ( `http://${ location . hostname } :14168/upload` , {
2098+ method : 'DELETE' , headers : { 'Content-Type' : 'application/json' } ,
2099+ body : JSON . stringify ( { path : removed . path } ) ,
2100+ } ) . catch ( ( ) => { } ) ;
2101+ }
2102+ }
2103+ // 文本里现存的"完整有效"占位符的 sid 集合
2104+ function placeholderSidsInText ( text ) {
2105+ const ids = new Set ( ) ; const re = / \[ (?: I m a g e | F i l e ) # ( \d + ) \] / g; let m ;
2106+ while ( ( m = re . exec ( text ) ) !== null ) ids . add ( Number ( m [ 1 ] ) ) ;
2107+ return ids ;
2108+ }
2109+ // #1 对账:占位符被删掉或改坏 → 同步移除对应附件(缩略图 + 磁盘文件)
2110+ function reconcilePendingFiles ( ) {
2111+ if ( ! state . pendingFiles . length ) return ;
2112+ const present = placeholderSidsInText ( inputEl . value ) ;
2113+ for ( const f of state . pendingFiles . filter ( x => ! present . has ( x . sid ) ) ) {
2114+ removePendingFile ( f . sid , { stripPlaceholder : false } ) ;
2115+ }
2116+ }
2117+ // #2 原子删除:光标紧邻占位符时 Backspace/Delete 整块删(连同文件),杜绝"删一半"
2118+ function atomicPlaceholderDelete ( e ) {
2119+ if ( e . key !== 'Backspace' && e . key !== 'Delete' ) return false ;
2120+ if ( inputEl . selectionStart !== inputEl . selectionEnd ) return false ; // 有选区交给默认行为 + 对账兜底
2121+ const pos = inputEl . selectionStart ;
2122+ const val = inputEl . value ;
2123+ const re = / \[ (?: I m a g e | F i l e ) # ( \d + ) \] / g; let m ;
2124+ while ( ( m = re . exec ( val ) ) !== null ) {
2125+ const start = m . index , end = start + m [ 0 ] . length , sid = Number ( m [ 1 ] ) ;
2126+ const hit = e . key === 'Backspace' ? end === pos : start === pos ;
2127+ if ( ! hit ) continue ;
2128+ e . preventDefault ( ) ;
2129+ let s = start , eOut = end ; // 顺带吃掉紧邻的一个空格,避免残留双空格
2130+ if ( e . key === 'Backspace' && val [ s - 1 ] === ' ' ) s -= 1 ;
2131+ else if ( e . key === 'Delete' && val [ eOut ] === ' ' ) eOut += 1 ;
2132+ inputEl . value = val . slice ( 0 , s ) + val . slice ( eOut ) ;
2133+ inputEl . setSelectionRange ( s , s ) ;
2134+ removePendingFile ( sid , { stripPlaceholder : false } ) ;
2135+ inputEl . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
2136+ return true ;
2137+ }
2138+ return false ;
2139+ }
2140+
20842141async function uploadOne ( name , dataUrl , sid ) {
20852142 const res = await fetch ( `http://${ location . hostname } :14168/upload` , {
20862143 method : 'POST' ,
@@ -2153,21 +2210,7 @@ if (imgInput) imgInput.addEventListener('change', () => {
21532210if ( thumbStrip ) thumbStrip . addEventListener ( 'click' , ( e ) => {
21542211 const x = e . target . closest ( '.x' ) ;
21552212 if ( x ) {
2156- const sid = Number ( x . dataset . sid ) ;
2157- const idx = state . pendingFiles . findIndex ( f => f . sid === sid ) ;
2158- if ( idx >= 0 ) {
2159- const removed = state . pendingFiles [ idx ] ;
2160- state . pendingFiles . splice ( idx , 1 ) ;
2161- removePlaceholderFromComposer ( removed ) ;
2162- renderThumbStrip ( ) ;
2163- if ( removed . path ) {
2164- fetch ( `http://${ location . hostname } :14168/upload` , {
2165- method : 'DELETE' ,
2166- headers : { 'Content-Type' : 'application/json' } ,
2167- body : JSON . stringify ( { path : removed . path } ) ,
2168- } ) . catch ( ( ) => { } ) ;
2169- }
2170- }
2213+ removePendingFile ( Number ( x . dataset . sid ) , { stripPlaceholder : true } ) ;
21712214 return ;
21722215 }
21732216 const fileChip = e . target . closest ( '.file-chip.pending' ) ;
0 commit comments