@@ -180,22 +180,13 @@ export default function AIPage() {
}
} , [ aiText , mac ] ) ;
// ── 把后端消息行映射为 UI 消息( type=0 聊天,type=2 优化卡片) ──
// ── 把后端消息行映射为 UI 消息 ──
// 只要助手消息带有 before_peq + after_peq,就展示为 AB 对比卡片;
// 其余按 type=0 聊天消息处理;用户侧的 type=2 仍然只是内部 prompt,跳过。
const mapRowsToUI = useCallback ( ( rows : MessageRead [ ] ) : UIMessage [ ] = > {
const result : UIMessage [ ] = [ ] ;
for ( const r of rows ) {
if ( r . type === 0 ) {
const { text , tools } = extractText ( r . content ) ;
if ( ! text && tools . length === 0 ) continue ;
result . push ( {
kind : "chat" ,
id : r.id ,
role : r.role ,
text ,
tools : tools.map ( ( t ) = > ( { . . . t , ok : true } ) ) ,
} ) ;
} else if ( r . type === 2 && r . role === "assistant" && r . before_peq && r . after_peq ) {
// 只展示助手侧的优化记录;用户侧(type=2, role=user)只是内部 prompt
if ( r . role === "user" && r . before_peq && r . after_peq ) {
result . push ( {
kind : "optimize" ,
id : r.id ,
@@ -205,6 +196,16 @@ export default function AIPage() {
applied : ! ! r . applied ,
appliedAt : r.applied_at ? ? null ,
} ) ;
} else if ( r . type === 0 ) {
const { text , tools } = extractText ( r . content ) ;
if ( ! text && tools . length === 0 ) continue ;
result . push ( {
kind : "chat" ,
id : r.id ,
role : r.role ,
text ,
tools : tools.map ( ( t ) = > ( { . . . t , ok : true } ) ) ,
} ) ;
}
}
return result ;
@@ -267,7 +268,7 @@ export default function AIPage() {
( question : string ) = > {
const trimmed = question . trim ( ) ;
if ( ! trimmed || sending ) return ;
if ( ! mac ) {
if ( ! mac || ! deviceState || ! api ) {
toast . error ( aiText . deviceRequired ) ;
return ;
}
@@ -302,12 +303,42 @@ export default function AIPage() {
// 工具执行后续调用时需要保留当前 chat_id,先捕获一下
let currentChatId = chatId ;
// 组装新版 /sse/question 请求体
const buildAndStream = async ( ) = > {
let devicePeq : any ;
try {
const peqState = await api . getPeqState ( ) ;
devicePeq = {
peqSelect : peqState.peqSelect ? ? deviceState . peqSelect ? ? 0 ,
peqEnable : deviceState.peqEnable ? ? 1 ,
peq : ( peqState . peq ? ? [ ] ) . map ( ( p ) = > ( {
name : p.name ,
brand : p.brand ,
model : p.model ,
filters : p.filters ? ? [ ] ,
preamp : p.preamp ,
canDel : p.canDel ,
autoPre : p.autoPre ,
} ) ) ,
msgCount : deviceState.msgCount ? ? 0 ,
} ;
} catch ( e : any ) {
updateAssistant ( ( m ) = > ( {
. . . m ,
pending : false ,
error : true ,
text : aiText.requestFailed ,
} ) ) ;
setSending ( false ) ;
toast . error ( formatText ( aiText . requestFailed , { message : e?.message ? ? e } ) ) ;
return ;
}
const controller = streamQuestion (
{
question : trimmed ,
language ,
mac ,
device : deviceName ,
device_setting : { . . . deviceState } ,
device_peq : devicePeq ,
chat_id : currentChatId ,
} ,
{
@@ -373,8 +404,10 @@ export default function AIPage() {
} ,
) ;
abortRef . current = controller ;
} ;
void buildAndStream ( ) ;
} ,
[ aiText , api , chatId , deviceName , language , mac , refreshCurrentChat , sending ] ,
[ aiText , api , chatId , deviceState , mac , refreshCurrentChat , sending ] ,
) ;
const stop = useCallback ( ( ) = > {
@@ -417,6 +450,44 @@ export default function AIPage() {
// ── 优化记录:对比对话框 & 应用/回滚 ──
const [ compareMsg , setCompareMsg ] = useState < OptimizeUIMessage | null > ( null ) ;
const [ applyingId , setApplyingId ] = useState < string | null > ( null ) ;
const [ currentDevicePeq , setCurrentDevicePeq ] = useState < OptimizePeqPayload | null > ( null ) ;
// 拉取当前设备 PEQ,作为"当前"参考曲线与"应用/回滚"后的对比基准
const refreshCurrentDevicePeq = useCallback ( async ( ) = > {
if ( ! api ) return ;
try {
const peqState = await api . getPeqState ( ) ;
const selectedIdx = peqState . peqSelect ? ? 0 ;
const selected = peqState . peq ? . [ selectedIdx ] ;
const selectedFilters = ( ( ) = > {
const f = selected ? . filters ;
if ( ! f ) return undefined ;
if ( typeof f === "string" ) {
try {
return JSON . parse ( f ) ;
} catch {
return undefined ;
}
}
return f ;
} ) ( ) ;
setCurrentDevicePeq ( {
name : selected?.name ,
preamp : selected?.preamp ,
canDel : selected?.canDel ,
autoPre : selected?.autoPre ,
brand : selected?.brand ,
model : selected?.model ,
filters : ( selectedFilters ? ? peqState . filters ? ? [ ] ) as OptimizePeqPayload [ "filters" ] ,
} ) ;
} catch {
// 静默失败,保留上一次的值
}
} , [ api ] ) ;
useEffect ( ( ) = > {
if ( isConnected ) void refreshCurrentDevicePeq ( ) ;
} , [ isConnected , refreshCurrentDevicePeq ] ) ;
const handleApplyToggle = useCallback (
async ( msg : OptimizeUIMessage ) = > {
@@ -453,6 +524,7 @@ export default function AIPage() {
setCompareMsg ( ( cur ) = >
cur && cur . id === msg . id ? { . . . cur , applied : nextApplied } : cur ,
) ;
void refreshCurrentDevicePeq ( ) ;
toast . success (
nextApplied ? aiText.optimize.applySuccess : aiText.optimize.rollbackSuccess ,
) ;
@@ -462,7 +534,15 @@ export default function AIPage() {
setApplyingId ( null ) ;
}
} ,
[ aiText , api ] ,
[ aiText , api , refreshCurrentDevicePeq ] ,
) ;
const openCompare = useCallback (
( m : OptimizeUIMessage ) = > {
setCompareMsg ( m ) ;
void refreshCurrentDevicePeq ( ) ;
} ,
[ refreshCurrentDevicePeq ] ,
) ;
const headerTitle = aiText . title ;
@@ -508,7 +588,6 @@ export default function AIPage() {
{ /* ── Messages / Welcome ── */ }
< div ref = { scrollRef } className = "flex-1 min-h-0 overflow-y-auto px-4" >
< div className = "mx-auto w-full max-w-[720px]" >
{ isEmpty ? (
< WelcomeView
aiText = { aiText }
@@ -528,8 +607,9 @@ export default function AIPage() {
key = { m . id }
aiText = { aiText }
msg = { m }
currentPeq = { currentDevicePeq }
applying = { applyingId === m . id }
onCompare = { ( ) = > setCompareMsg ( m ) }
onCompare = { ( ) = > openCompare ( m ) }
onApplyToggle = { ( ) = > handleApplyToggle ( m ) }
/ >
) ,
@@ -544,7 +624,6 @@ export default function AIPage() {
< / div >
) }
< / div >
< / div >
{ /* ── Input bar ── */ }
< div
@@ -558,7 +637,7 @@ export default function AIPage() {
>
< form
onSubmit = { onSubmit }
className = "flex items-end gap-2 rounded-[22px] px-3 py-2 mx-auto w-full max-w-[720px] "
className = "flex items-end gap-2 rounded-[22px] px-3 py-2 w-full "
style = { {
background : "rgba(28,28,30,0.92)" ,
border : "1px solid rgba(255,255,255,0.08)" ,
@@ -611,7 +690,7 @@ export default function AIPage() {
) }
< / form >
{ chatId && messages . length > 0 && (
< div className = "flex justify-center mt-2 mx-auto w-full max-w-[720px] " >
< div className = "flex justify-center mt-2" >
< button
onClick = { clearCurrent }
className = "text-[11px] text-white/35 active:text-white/70 transition-colors px-2"
@@ -639,6 +718,7 @@ export default function AIPage() {
< CompareDialog
aiText = { aiText }
msg = { compareMsg }
currentPeq = { currentDevicePeq }
applying = { applyingId === compareMsg . id }
onClose = { ( ) = > setCompareMsg ( null ) }
onApplyToggle = { ( ) = > handleApplyToggle ( compareMsg ) }
@@ -899,18 +979,23 @@ function HistoryPanel({
function OptimizeCard ( {
aiText ,
msg ,
currentPeq ,
applying ,
onCompare ,
onApplyToggle ,
} : {
aiText : AILocale ;
msg : OptimizeUIMessage ;
currentPeq : OptimizePeqPayload | null ;
applying : boolean ;
onCompare : ( ) = > void ;
onApplyToggle : ( ) = > void ;
} ) {
const filterCount = msg . afterPeq . filters ? . length ? ? 0 ;
const preamp = msg . afterPeq . preamp ? ? 0 ;
// 当前未应用 → 目标是 after_peq(点击应用即切到 after)
// 当前已应用 → 目标是 before_peq(点击回滚即切回 before)
const targetPeq = msg . applied ? msg.beforePeq : msg.afterPeq ;
const filterCount = targetPeq . filters ? . length ? ? 0 ;
const preamp = targetPeq . preamp ? ? 0 ;
const summary = formatText ( aiText . optimize . summary , {
count : filterCount ,
preamp : ` ${ preamp > 0 ? "+" : "" } ${ preamp . toFixed ( 1 ) } ` ,
@@ -958,10 +1043,10 @@ function OptimizeCard({
< / div >
< div className = "text-[12px] text-white/45 mt-0.5" > { summary } < / div >
{ /* 缩略对比曲线 */ }
{ /* 缩略对比曲线:当前设备 vs 应用/回滚后 */ }
< ComparePreview
beforePeq = { msg . beforePeq }
afterPeq = { msg . afterPeq }
currentPeq = { currentPeq }
targetPeq = { targetPeq }
width = { 260 }
height = { 70 }
/ >
@@ -1073,18 +1158,21 @@ function CurveSvg({
}
function ComparePreview ( {
beforePeq ,
afterPeq ,
currentPeq ,
targetPeq ,
width ,
height ,
} : {
beforePeq : OptimizePeqPayload ;
afterPeq : OptimizePeqPayload ;
currentPeq : OptimizePeqPayload | null ;
targetPeq : OptimizePeqPayload ;
width : number ;
height : number ;
} ) {
const beforeBands = useMemo ( ( ) = > peqToBands ( beforePeq ) , [ beforePeq ] ) ;
const afterBands = useMemo ( ( ) = > peqToBands ( afterPeq ) , [ afterPeq ] ) ;
const currentBands = useMemo (
( ) = > ( currentPeq ? peqToBands ( currentPeq ) : [ ] ) ,
[ currentPeq ] ,
) ;
const targetBands = useMemo ( ( ) = > peqToBands ( targetPeq ) , [ targetPeq ] ) ;
return (
< div
className = "relative mt-3 rounded-[10px] overflow-hidden"
@@ -1093,11 +1181,13 @@ function ComparePreview({
border : "1px solid rgba(255,255,255,0.05)" ,
} }
>
{ currentBands . length > 0 && (
< div className = "absolute inset-0" >
< CurveSvg bands = { beforeBands } width = { width } height = { height } stroke = "rgba(255,255,255,0.35)" / >
< CurveSvg bands = { currentBands } width = { width } height = { height } stroke = "rgba(255,255,255,0.35)" / >
< / div >
) }
< div className = "relative" >
< CurveSvg bands = { afterBands } width = { width } height = { height } stroke = "#00FFF6" fill = "rgba(0,255,246,0.08)" / >
< CurveSvg bands = { targetBands } width = { width } height = { height } stroke = "#00FFF6" fill = "rgba(0,255,246,0.08)" / >
< / div >
< / div >
) ;
@@ -1109,18 +1199,30 @@ function ComparePreview({
function CompareDialog ( {
aiText ,
msg ,
currentPeq ,
applying ,
onClose ,
onApplyToggle ,
} : {
aiText : AILocale ;
msg : OptimizeUIMessage ;
currentPeq : OptimizePeqPayload | null ;
applying : boolean ;
onClose : ( ) = > void ;
onApplyToggle : ( ) = > void ;
} ) {
const beforeBands = useMemo ( ( ) = > peqToBands ( msg . beforePeq ) , [ msg . beforePeq ] ) ;
const afterBands = useMemo ( ( ) = > peqToBands ( msg . afterPeq ) , [ msg . afterPeq ] ) ;
// 未应用 → 目标 = after_peq,图例右侧显示“应用后”
// 已应用 → 目标 = before_peq,图例右侧显示“回滚后”
const targetPeq = msg . applied ? msg.beforePeq : msg.afterPeq ;
const currentBands = useMemo (
( ) = > ( currentPeq ? peqToBands ( currentPeq ) : [ ] ) ,
[ currentPeq ] ,
) ;
const targetBands = useMemo ( ( ) = > peqToBands ( targetPeq ) , [ targetPeq ] ) ;
const currentLabel = aiText . compareDialog . legendCurrent ;
const targetLabel = msg . applied
? aiText.compareDialog.legendAfterRollback
: aiText.compareDialog.legendAfterApply ;
const chartWidth = 340 ;
const chartHeight = 160 ;
@@ -1173,25 +1275,27 @@ function CompareDialog({
className = "block w-3 h-[2px] rounded"
style = { { background : "rgba(255,255,255,0.45)" } }
/ >
{ aiText . compareDialog . legendBefore }
{ currentLabel }
< / span >
< span className = "inline-flex items-center gap-1.5" style = { { color : "#00FFF6" } } >
< span className = "block w-3 h-[2px] rounded" style = { { background : "#00FFF6" } } / >
{ aiText . compareDialog . legendAfter }
{ targetLabel }
< / span >
< / div >
< div className = "relative" style = { { height : chartHeight } } >
{ currentBands . length > 0 && (
< div className = "absolute inset-0" >
< CurveSvg
bands = { beforeBands }
bands = { currentBands }
width = { chartWidth }
height = { chartHeight }
stroke = "rgba(255,255,255,0.45)"
/ >
< / div >
) }
< div className = "absolute inset-0" >
< CurveSvg
bands = { afterBands }
bands = { targetBands }
width = { chartWidth }
height = { chartHeight }
stroke = "#00FFF6"
@@ -1209,18 +1313,18 @@ function CompareDialog({
< / div >
< / div >
{ /* 参数对比表 */ }
{ /* 参数对比表:当前设备 vs 应用/回滚后 */ }
< div className = "mt-3 grid grid-cols-2 gap-2" >
< ParamCard
aiText = { aiText }
label = { aiText . compareDialog . legendBefore }
peq = { msg . beforePeq }
label = { currentLabel }
peq = { currentPeq }
muted
/ >
< ParamCard
aiText = { aiText }
label = { aiText . compareDialog . legendAfter }
peq = { msg . afterPeq }
label = { targetLabel }
peq = { targetPeq }
highlight
/ >
< / div >
@@ -1283,14 +1387,14 @@ function ParamCard({
} : {
aiText : AILocale ;
label : string ;
peq : OptimizePeqPayload ;
peq : OptimizePeqPayload | null ;
muted? : boolean ;
highlight? : boolean ;
} ) {
const filters = peq . filters ? ? [ ] ;
const filters = peq ? . filters ? ? [ ] ;
const summary = formatText ( aiText . compareDialog . paramSummary , {
count : filters.length ,
preamp : ( peq . preamp ? ? 0 ) . toFixed ( 1 ) ,
preamp : ( peq ? . preamp ? ? 0 ) . toFixed ( 1 ) ,
} ) ;
return (
< div