@@ -972,11 +972,23 @@ func (m *Model) dashToggleAll() {
972972// Proactive insights (LLM-powered)
973973// ---------------------------------------------------------------------------
974974
975- // insightsResultMsg delivers the result of an async insights fetch.
976- // Generation correlates the result with the request that produced it;
977- // stale results from canceled requests are discarded by the handler.
975+ // insightsResultMsg delivers an error from an insights fetch that failed
976+ // before streaming could begin (no data, stream start failure).
978977type insightsResultMsg struct {
979- Items []insightItem
978+ Err error
979+ Generation uint64
980+ }
981+
982+ // insightsStreamStartedMsg delivers the stream channel to the update loop.
983+ type insightsStreamStartedMsg struct {
984+ Channel <- chan llm.StreamChunk
985+ Generation uint64
986+ }
987+
988+ // insightsChunkMsg carries one streamed token from the insights LLM call.
989+ type insightsChunkMsg struct {
990+ Content string
991+ Done bool
980992 Err error
981993 Generation uint64
982994}
@@ -1004,11 +1016,11 @@ func (m *Model) cancelInsights() {
10041016 m .dash .insights .cancel ()
10051017 m .dash .insights .cancel = nil
10061018 }
1019+ m .dash .insights .streamCh = nil
10071020}
10081021
1009- // fetchInsights starts an async LLM call to generate proactive insights.
1010- // Returns a tea.Cmd that resolves to insightsResultMsg. The returned cmd
1011- // runs in a background goroutine.
1022+ // fetchInsights starts a streaming LLM call to generate proactive insights.
1023+ // Items appear incrementally as each complete JSON object streams in.
10121024func (m * Model ) fetchInsights () tea.Cmd {
10131025 if ! m .insightsWanted () {
10141026 return nil
@@ -1018,6 +1030,7 @@ func (m *Model) fetchInsights() tea.Cmd {
10181030 m .dash .insights .loading = true
10191031 m .dash .insights .err = nil
10201032 m .dash .insights .generation ++
1033+ m .dash .insights .streamBuf .Reset ()
10211034
10221035 client := m .llmClient
10231036 store := m .store
@@ -1046,40 +1059,82 @@ func (m *Model) fetchInsights() tea.Cmd {
10461059 {Role : "user" , Content : "Analyze my home data and provide proactive insights." },
10471060 }
10481061
1049- raw , err := client .ChatComplete (
1062+ streamCh , err := client .ChatStream (
10501063 ctx , messages ,
10511064 llm .WithJSONSchema ("insights" , llm .InsightsJSONSchema ()),
10521065 llm .WithNoThinking (),
10531066 )
10541067 if err != nil {
10551068 return insightsResultMsg {Err : err , Generation : gen }
10561069 }
1057- if strings .TrimSpace (raw ) == "" {
1058- return insightsResultMsg {
1059- Err : fmt .Errorf (
1060- "model returned empty response -- try a larger context_length or smaller dataset" ,
1061- ),
1062- Generation : gen ,
1063- }
1070+ return insightsStreamStartedMsg {Channel : streamCh , Generation : gen }
1071+ }
1072+ }
1073+
1074+ // waitForInsightChunk returns a Cmd that reads the next chunk from the
1075+ // insights stream channel.
1076+ func waitForInsightChunk (ch <- chan llm.StreamChunk , gen uint64 ) tea.Cmd {
1077+ return waitForStream (ch , func (c llm.StreamChunk ) tea.Msg {
1078+ return insightsChunkMsg {
1079+ Content : c .Content ,
1080+ Done : c .Done ,
1081+ Err : c .Err ,
1082+ Generation : gen ,
10641083 }
1084+ }, insightsChunkMsg {Done : true , Generation : gen })
1085+ }
10651086
1066- var result struct {
1067- Insights []insightItem `json:"insights"`
1087+ // parsePartialInsights extracts complete insightItem objects from a partial
1088+ // JSON stream. It finds balanced top-level {...} blocks within the first
1089+ // JSON array and parses each individually, so items appear as they complete.
1090+ func parsePartialInsights (partial string ) []insightItem {
1091+ // Find the opening bracket of the insights array.
1092+ idx := strings .Index (partial , "[" )
1093+ if idx < 0 {
1094+ return nil
1095+ }
1096+
1097+ var items []insightItem
1098+ depth := 0
1099+ inString := false
1100+ escaped := false
1101+ objStart := - 1
1102+ for i := idx ; i < len (partial ); i ++ {
1103+ ch := partial [i ]
1104+ if escaped {
1105+ escaped = false
1106+ continue
10681107 }
1069- if err := json .Unmarshal ([]byte (raw ), & result ); err != nil {
1070- return insightsResultMsg {Err : fmt .Errorf ("parse insights: %w" , err ), Generation : gen }
1108+ if ch == '\\' && inString {
1109+ escaped = true
1110+ continue
10711111 }
1072-
1073- // Defense-in-depth: drop insights the LLM returned without a valid entity.
1074- valid := result .Insights [:0 ]
1075- for _ , item := range result .Insights {
1076- if item .EntityID >= 1 {
1077- valid = append (valid , item )
1112+ if ch == '"' {
1113+ inString = ! inString
1114+ continue
1115+ }
1116+ if inString {
1117+ continue
1118+ }
1119+ switch ch {
1120+ case '{' :
1121+ if depth == 0 {
1122+ objStart = i
1123+ }
1124+ depth ++
1125+ case '}' :
1126+ depth --
1127+ if depth == 0 && objStart >= 0 {
1128+ var item insightItem
1129+ if err := json .Unmarshal ([]byte (partial [objStart :i + 1 ]), & item ); err == nil &&
1130+ item .EntityID >= 1 {
1131+ items = append (items , item )
1132+ }
1133+ objStart = - 1
10781134 }
10791135 }
1080-
1081- return insightsResultMsg {Items : valid , Generation : gen }
10821136 }
1137+ return items
10831138}
10841139
10851140// refreshInsights cancels any in-flight request and starts a fresh fetch.
@@ -1153,29 +1208,58 @@ func tabAbbrev(tab string) string {
11531208 return tab
11541209}
11551210
1156- // dashInsightsRows returns dashboard rows for the insights section.
1211+ // dashInsightsRows returns dashboard rows for the insights section,
1212+ // grouped by category with sub-header rows.
11571213func (m * Model ) dashInsightsRows () []dashRow {
11581214 ins := m .dash .insights
11591215 if len (ins .items ) == 0 {
11601216 return nil
11611217 }
1162- rows := make ([]dashRow , 0 , len (ins .items ))
1218+
1219+ // Group items by category in display order.
1220+ order := []insightCategory {insightAttention , insightStale , insightPattern }
1221+ grouped := make (map [insightCategory ][]insightItem , len (order ))
11631222 for _ , item := range ins .items {
1164- target := & dashNavEntry {Section : dashSectionInsights , InfoOnly : true }
1165- if tab , ok := tabKindFromString (item .Tab ); ok {
1166- target = & dashNavEntry {
1167- Tab : tab ,
1168- ID : item .EntityID ,
1169- Section : dashSectionInsights ,
1170- }
1223+ grouped [item .Category ] = append (grouped [item .Category ], item )
1224+ }
1225+
1226+ var rows []dashRow
1227+ for _ , cat := range order {
1228+ items := grouped [cat ]
1229+ if len (items ) == 0 {
1230+ continue
11711231 }
1232+ // Category sub-header row.
11721233 rows = append (rows , dashRow {
11731234 Cells : []dashCell {
1174- {Text : item .Text , Style : m .styles .DashValue ()},
1175- {Text : tabAbbrev (item .Tab ), Style : m .styles .DashLabel (), Align : alignRight },
1235+ {
1236+ Text : insightCategoryLabel (cat ),
1237+ Style : m .styles .DashLabel (),
1238+ },
11761239 },
1177- Target : target ,
1240+ Target : & dashNavEntry { Section : dashSectionInsights , InfoOnly : true } ,
11781241 })
1242+ for _ , item := range items {
1243+ target := & dashNavEntry {Section : dashSectionInsights , InfoOnly : true }
1244+ if tab , ok := tabKindFromString (item .Tab ); ok {
1245+ target = & dashNavEntry {
1246+ Tab : tab ,
1247+ ID : item .EntityID ,
1248+ Section : dashSectionInsights ,
1249+ }
1250+ }
1251+ rows = append (rows , dashRow {
1252+ Cells : []dashCell {
1253+ {Text : item .Text , Style : m .styles .DashValue ()},
1254+ {
1255+ Text : tabAbbrev (item .Tab ),
1256+ Style : m .styles .DashLabel (),
1257+ Align : alignRight ,
1258+ },
1259+ },
1260+ Target : target ,
1261+ })
1262+ }
11791263 }
11801264 return rows
11811265}
0 commit comments