session.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. package cmd
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "os"
  9. "os/exec"
  10. "runtime"
  11. "strings"
  12. "syscall"
  13. "time"
  14. "charm.land/lipgloss/v2"
  15. "github.com/charmbracelet/colorprofile"
  16. "github.com/charmbracelet/crush/internal/config"
  17. "github.com/charmbracelet/crush/internal/db"
  18. "github.com/charmbracelet/crush/internal/event"
  19. "github.com/charmbracelet/crush/internal/message"
  20. "github.com/charmbracelet/crush/internal/session"
  21. "github.com/charmbracelet/crush/internal/ui/chat"
  22. "github.com/charmbracelet/crush/internal/ui/styles"
  23. "github.com/charmbracelet/x/ansi"
  24. "github.com/charmbracelet/x/exp/charmtone"
  25. "github.com/charmbracelet/x/term"
  26. "github.com/spf13/cobra"
  27. )
  28. var sessionCmd = &cobra.Command{
  29. Use: "session",
  30. Aliases: []string{"sessions", "s"},
  31. Short: "Manage sessions",
  32. Long: "Manage Crush sessions. Agents can use --json for machine-readable output.",
  33. }
  34. var (
  35. sessionListJSON bool
  36. sessionShowJSON bool
  37. sessionLastJSON bool
  38. sessionDeleteJSON bool
  39. sessionRenameJSON bool
  40. )
  41. var sessionListCmd = &cobra.Command{
  42. Use: "list",
  43. Aliases: []string{"ls"},
  44. Short: "List all sessions",
  45. Long: "List all sessions. Use --json for machine-readable output.",
  46. RunE: runSessionList,
  47. }
  48. var sessionShowCmd = &cobra.Command{
  49. Use: "show <id>",
  50. Short: "Show session details",
  51. Long: "Show session details. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
  52. Args: cobra.ExactArgs(1),
  53. RunE: runSessionShow,
  54. }
  55. var sessionLastCmd = &cobra.Command{
  56. Use: "last",
  57. Short: "Show most recent session",
  58. Long: "Show the last updated session. Use --json for machine-readable output.",
  59. RunE: runSessionLast,
  60. }
  61. var sessionDeleteCmd = &cobra.Command{
  62. Use: "delete <id>",
  63. Aliases: []string{"rm"},
  64. Short: "Delete a session",
  65. Long: "Delete a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
  66. Args: cobra.ExactArgs(1),
  67. RunE: runSessionDelete,
  68. }
  69. var sessionRenameCmd = &cobra.Command{
  70. Use: "rename <id> <title>",
  71. Short: "Rename a session",
  72. Long: "Rename a session by ID. Use --json for machine-readable output. ID can be a UUID, full hash, or hash prefix.",
  73. Args: cobra.MinimumNArgs(2),
  74. RunE: runSessionRename,
  75. }
  76. func init() {
  77. sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "output in JSON format")
  78. sessionShowCmd.Flags().BoolVar(&sessionShowJSON, "json", false, "output in JSON format")
  79. sessionLastCmd.Flags().BoolVar(&sessionLastJSON, "json", false, "output in JSON format")
  80. sessionDeleteCmd.Flags().BoolVar(&sessionDeleteJSON, "json", false, "output in JSON format")
  81. sessionRenameCmd.Flags().BoolVar(&sessionRenameJSON, "json", false, "output in JSON format")
  82. sessionCmd.AddCommand(sessionListCmd)
  83. sessionCmd.AddCommand(sessionShowCmd)
  84. sessionCmd.AddCommand(sessionLastCmd)
  85. sessionCmd.AddCommand(sessionDeleteCmd)
  86. sessionCmd.AddCommand(sessionRenameCmd)
  87. }
  88. type sessionServices struct {
  89. sessions session.Service
  90. messages message.Service
  91. }
  92. func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
  93. dataDir, _ := cmd.Flags().GetString("data-dir")
  94. ctx := cmd.Context()
  95. if dataDir == "" {
  96. cfg, err := config.Init("", "", false)
  97. if err != nil {
  98. return nil, nil, nil, fmt.Errorf("failed to initialize config: %w", err)
  99. }
  100. dataDir = cfg.Config().Options.DataDirectory
  101. }
  102. conn, err := db.Connect(ctx, dataDir)
  103. if err != nil {
  104. return nil, nil, nil, fmt.Errorf("failed to connect to database: %w", err)
  105. }
  106. queries := db.New(conn)
  107. svc := &sessionServices{
  108. sessions: session.NewService(queries, conn),
  109. messages: message.NewService(queries),
  110. }
  111. return ctx, svc, func() { conn.Close() }, nil
  112. }
  113. func runSessionList(cmd *cobra.Command, _ []string) error {
  114. event.SetNonInteractive(true)
  115. event.SessionListed(sessionListJSON)
  116. ctx, svc, cleanup, err := sessionSetup(cmd)
  117. if err != nil {
  118. return err
  119. }
  120. defer cleanup()
  121. list, err := svc.sessions.List(ctx)
  122. if err != nil {
  123. return fmt.Errorf("failed to list sessions: %w", err)
  124. }
  125. if sessionListJSON {
  126. out := cmd.OutOrStdout()
  127. output := make([]sessionJSON, len(list))
  128. for i, s := range list {
  129. output[i] = sessionJSON{
  130. ID: session.HashID(s.ID),
  131. UUID: s.ID,
  132. Title: s.Title,
  133. Created: time.Unix(s.CreatedAt, 0).Format(time.RFC3339),
  134. Modified: time.Unix(s.UpdatedAt, 0).Format(time.RFC3339),
  135. }
  136. }
  137. enc := json.NewEncoder(out)
  138. enc.SetEscapeHTML(false)
  139. return enc.Encode(output)
  140. }
  141. w, cleanup, usingPager := sessionWriter(ctx, len(list))
  142. defer cleanup()
  143. hashStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
  144. dateStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
  145. width := sessionOutputWidth
  146. if tw, _, err := term.GetSize(os.Stdout.Fd()); err == nil && tw > 0 {
  147. width = tw
  148. }
  149. // 7 (hash) + 1 (space) + 25 (RFC3339 date) + 1 (space) = 34 chars prefix.
  150. titleWidth := width - 34
  151. if titleWidth < 10 {
  152. titleWidth = 10
  153. }
  154. var writeErr error
  155. for _, s := range list {
  156. hash := session.HashID(s.ID)[:7]
  157. date := time.Unix(s.CreatedAt, 0).Format(time.RFC3339)
  158. title := strings.ReplaceAll(s.Title, "\n", " ")
  159. title = ansi.Truncate(title, titleWidth, "…")
  160. _, writeErr = fmt.Fprintln(w, hashStyle.Render(hash), dateStyle.Render(date), title)
  161. if writeErr != nil {
  162. break
  163. }
  164. }
  165. if writeErr != nil && usingPager && isBrokenPipe(writeErr) {
  166. return nil
  167. }
  168. return writeErr
  169. }
  170. type sessionJSON struct {
  171. ID string `json:"id"`
  172. UUID string `json:"uuid"`
  173. Title string `json:"title"`
  174. Created string `json:"created"`
  175. Modified string `json:"modified"`
  176. }
  177. type sessionMutationResult struct {
  178. ID string `json:"id"`
  179. UUID string `json:"uuid"`
  180. Title string `json:"title"`
  181. Deleted bool `json:"deleted,omitempty"`
  182. Renamed bool `json:"renamed,omitempty"`
  183. }
  184. // resolveSessionID resolves a session ID that can be a UUID, full hash, or hash prefix.
  185. // Returns an error if the prefix is ambiguous (matches multiple sessions).
  186. func resolveSessionID(ctx context.Context, svc session.Service, id string) (session.Session, error) {
  187. // Try direct UUID lookup first
  188. if s, err := svc.Get(ctx, id); err == nil {
  189. return s, nil
  190. }
  191. // List all sessions and check for hash matches
  192. sessions, err := svc.List(ctx)
  193. if err != nil {
  194. return session.Session{}, err
  195. }
  196. var matches []session.Session
  197. for _, s := range sessions {
  198. hash := session.HashID(s.ID)
  199. if hash == id || strings.HasPrefix(hash, id) {
  200. matches = append(matches, s)
  201. }
  202. }
  203. if len(matches) == 0 {
  204. return session.Session{}, fmt.Errorf("session not found: %s", id)
  205. }
  206. if len(matches) == 1 {
  207. return matches[0], nil
  208. }
  209. // Ambiguous - show matches like Git does
  210. var sb strings.Builder
  211. fmt.Fprintf(&sb, "session ID '%s' is ambiguous. Matches:\n\n", id)
  212. for _, m := range matches {
  213. hash := session.HashID(m.ID)
  214. created := time.Unix(m.CreatedAt, 0).Format("2006-01-02")
  215. // Keep title on one line by replacing newlines with spaces, and truncate.
  216. title := strings.ReplaceAll(m.Title, "\n", " ")
  217. title = ansi.Truncate(title, 50, "…")
  218. fmt.Fprintf(&sb, " %s... %q (created %s)\n", hash[:12], title, created)
  219. }
  220. sb.WriteString("\nUse more characters or the full hash")
  221. return session.Session{}, errors.New(sb.String())
  222. }
  223. func runSessionShow(cmd *cobra.Command, args []string) error {
  224. event.SetNonInteractive(true)
  225. event.SessionShown(sessionShowJSON)
  226. ctx, svc, cleanup, err := sessionSetup(cmd)
  227. if err != nil {
  228. return err
  229. }
  230. defer cleanup()
  231. sess, err := resolveSessionID(ctx, svc.sessions, args[0])
  232. if err != nil {
  233. return err
  234. }
  235. msgs, err := svc.messages.List(ctx, sess.ID)
  236. if err != nil {
  237. return fmt.Errorf("failed to list messages: %w", err)
  238. }
  239. msgPtrs := messagePtrs(msgs)
  240. if sessionShowJSON {
  241. return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
  242. }
  243. return outputSessionHuman(ctx, sess, msgPtrs)
  244. }
  245. func runSessionDelete(cmd *cobra.Command, args []string) error {
  246. event.SetNonInteractive(true)
  247. event.SessionDeletedCommand(sessionDeleteJSON)
  248. ctx, svc, cleanup, err := sessionSetup(cmd)
  249. if err != nil {
  250. return err
  251. }
  252. defer cleanup()
  253. sess, err := resolveSessionID(ctx, svc.sessions, args[0])
  254. if err != nil {
  255. return err
  256. }
  257. if err := svc.sessions.Delete(ctx, sess.ID); err != nil {
  258. return fmt.Errorf("failed to delete session: %w", err)
  259. }
  260. out := cmd.OutOrStdout()
  261. if sessionDeleteJSON {
  262. enc := json.NewEncoder(out)
  263. enc.SetEscapeHTML(false)
  264. return enc.Encode(sessionMutationResult{
  265. ID: session.HashID(sess.ID),
  266. UUID: sess.ID,
  267. Title: sess.Title,
  268. Deleted: true,
  269. })
  270. }
  271. fmt.Fprintf(out, "Deleted session %s\n", session.HashID(sess.ID)[:12])
  272. return nil
  273. }
  274. func runSessionRename(cmd *cobra.Command, args []string) error {
  275. event.SetNonInteractive(true)
  276. event.SessionRenamed(sessionRenameJSON)
  277. ctx, svc, cleanup, err := sessionSetup(cmd)
  278. if err != nil {
  279. return err
  280. }
  281. defer cleanup()
  282. sess, err := resolveSessionID(ctx, svc.sessions, args[0])
  283. if err != nil {
  284. return err
  285. }
  286. newTitle := strings.Join(args[1:], " ")
  287. if err := svc.sessions.Rename(ctx, sess.ID, newTitle); err != nil {
  288. return fmt.Errorf("failed to rename session: %w", err)
  289. }
  290. out := cmd.OutOrStdout()
  291. if sessionRenameJSON {
  292. enc := json.NewEncoder(out)
  293. enc.SetEscapeHTML(false)
  294. return enc.Encode(sessionMutationResult{
  295. ID: session.HashID(sess.ID),
  296. UUID: sess.ID,
  297. Title: newTitle,
  298. Renamed: true,
  299. })
  300. }
  301. fmt.Fprintf(out, "Renamed session %s to %q\n", session.HashID(sess.ID)[:12], newTitle)
  302. return nil
  303. }
  304. func runSessionLast(cmd *cobra.Command, _ []string) error {
  305. event.SetNonInteractive(true)
  306. event.SessionLastShown(sessionLastJSON)
  307. ctx, svc, cleanup, err := sessionSetup(cmd)
  308. if err != nil {
  309. return err
  310. }
  311. defer cleanup()
  312. list, err := svc.sessions.List(ctx)
  313. if err != nil {
  314. return fmt.Errorf("failed to list sessions: %w", err)
  315. }
  316. if len(list) == 0 {
  317. return fmt.Errorf("no sessions found")
  318. }
  319. sess := list[0]
  320. msgs, err := svc.messages.List(ctx, sess.ID)
  321. if err != nil {
  322. return fmt.Errorf("failed to list messages: %w", err)
  323. }
  324. msgPtrs := messagePtrs(msgs)
  325. if sessionLastJSON {
  326. return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
  327. }
  328. return outputSessionHuman(ctx, sess, msgPtrs)
  329. }
  330. const (
  331. sessionOutputWidth = 80
  332. sessionMaxContentWidth = 120
  333. )
  334. func messagePtrs(msgs []message.Message) []*message.Message {
  335. ptrs := make([]*message.Message, len(msgs))
  336. for i := range msgs {
  337. ptrs[i] = &msgs[i]
  338. }
  339. return ptrs
  340. }
  341. func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Message) error {
  342. output := sessionShowOutput{
  343. Meta: sessionShowMeta{
  344. ID: session.HashID(sess.ID),
  345. UUID: sess.ID,
  346. Title: sess.Title,
  347. Created: time.Unix(sess.CreatedAt, 0).Format(time.RFC3339),
  348. Modified: time.Unix(sess.UpdatedAt, 0).Format(time.RFC3339),
  349. Cost: sess.Cost,
  350. PromptTokens: sess.PromptTokens,
  351. CompletionTokens: sess.CompletionTokens,
  352. TotalTokens: sess.PromptTokens + sess.CompletionTokens,
  353. },
  354. Messages: make([]sessionShowMessage, len(msgs)),
  355. }
  356. for i, msg := range msgs {
  357. output.Messages[i] = sessionShowMessage{
  358. ID: msg.ID,
  359. Role: string(msg.Role),
  360. Created: time.Unix(msg.CreatedAt, 0).Format(time.RFC3339),
  361. Model: msg.Model,
  362. Provider: msg.Provider,
  363. Parts: convertParts(msg.Parts),
  364. }
  365. }
  366. enc := json.NewEncoder(w)
  367. enc.SetEscapeHTML(false)
  368. return enc.Encode(output)
  369. }
  370. func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
  371. sty := styles.DefaultStyles()
  372. toolResults := chat.BuildToolResultMap(msgs)
  373. width := sessionOutputWidth
  374. if w, _, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 {
  375. width = w
  376. }
  377. contentWidth := min(width, sessionMaxContentWidth)
  378. keyStyle := lipgloss.NewStyle().Foreground(charmtone.Damson)
  379. valStyle := lipgloss.NewStyle().Foreground(charmtone.Malibu)
  380. hash := session.HashID(sess.ID)[:12]
  381. created := time.Unix(sess.CreatedAt, 0).Format("Mon Jan 2 15:04:05 2006 -0700")
  382. // Render to buffer to determine actual height
  383. var buf strings.Builder
  384. fmt.Fprintln(&buf, keyStyle.Render("ID: ")+valStyle.Render(hash))
  385. fmt.Fprintln(&buf, keyStyle.Render("UUID: ")+valStyle.Render(sess.ID))
  386. fmt.Fprintln(&buf, keyStyle.Render("Title: ")+valStyle.Render(sess.Title))
  387. fmt.Fprintln(&buf, keyStyle.Render("Date: ")+valStyle.Render(created))
  388. fmt.Fprintln(&buf)
  389. first := true
  390. for _, msg := range msgs {
  391. items := chat.ExtractMessageItems(&sty, msg, toolResults)
  392. for _, item := range items {
  393. if !first {
  394. fmt.Fprintln(&buf)
  395. }
  396. first = false
  397. fmt.Fprintln(&buf, item.Render(contentWidth))
  398. }
  399. }
  400. fmt.Fprintln(&buf)
  401. contentHeight := strings.Count(buf.String(), "\n")
  402. w, cleanup, usingPager := sessionWriter(ctx, contentHeight)
  403. defer cleanup()
  404. _, err := io.WriteString(w, buf.String())
  405. // Ignore broken pipe errors when using a pager. This happens when the user
  406. // exits the pager early (e.g., pressing 'q' in less), which closes the pipe
  407. // and causes subsequent writes to fail. These errors are expected user behavior.
  408. if err != nil && usingPager && isBrokenPipe(err) {
  409. return nil
  410. }
  411. return err
  412. }
  413. func isBrokenPipe(err error) bool {
  414. if err == nil {
  415. return false
  416. }
  417. // Check for syscall.EPIPE (broken pipe)
  418. if errors.Is(err, syscall.EPIPE) {
  419. return true
  420. }
  421. // Also check for "broken pipe" in the error message
  422. return strings.Contains(err.Error(), "broken pipe")
  423. }
  424. // sessionWriter returns a writer, cleanup function, and a bool indicating if a pager is used.
  425. // When the content fits within the terminal (or stdout is not a TTY), it returns
  426. // a colorprofile.Writer wrapping stdout. When content exceeds terminal height,
  427. // it starts a pager process (respecting $PAGER, defaulting to "less -R").
  428. func sessionWriter(ctx context.Context, contentHeight int) (io.Writer, func(), bool) {
  429. // Use NewWriter which automatically detects TTY and strips ANSI when redirected
  430. if runtime.GOOS == "windows" || !term.IsTerminal(os.Stdout.Fd()) {
  431. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  432. }
  433. _, termHeight, err := term.GetSize(os.Stdout.Fd())
  434. if err != nil || contentHeight <= termHeight {
  435. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  436. }
  437. // Detect color profile from stderr since stdout is piped to the pager.
  438. profile := colorprofile.Detect(os.Stderr, os.Environ())
  439. pager := os.Getenv("PAGER")
  440. if pager == "" {
  441. pager = "less -R"
  442. }
  443. parts := strings.Fields(pager)
  444. cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) //nolint:gosec
  445. cmd.Stdout = os.Stdout
  446. cmd.Stderr = os.Stderr
  447. pipe, err := cmd.StdinPipe()
  448. if err != nil {
  449. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  450. }
  451. if err := cmd.Start(); err != nil {
  452. return colorprofile.NewWriter(os.Stdout, os.Environ()), func() {}, false
  453. }
  454. return &colorprofile.Writer{
  455. Forward: pipe,
  456. Profile: profile,
  457. }, func() {
  458. pipe.Close()
  459. _ = cmd.Wait()
  460. }, true
  461. }
  462. type sessionShowMeta struct {
  463. ID string `json:"id"`
  464. UUID string `json:"uuid"`
  465. Title string `json:"title"`
  466. Created string `json:"created"`
  467. Modified string `json:"modified"`
  468. Cost float64 `json:"cost"`
  469. PromptTokens int64 `json:"prompt_tokens"`
  470. CompletionTokens int64 `json:"completion_tokens"`
  471. TotalTokens int64 `json:"total_tokens"`
  472. }
  473. type sessionShowMessage struct {
  474. ID string `json:"id"`
  475. Role string `json:"role"`
  476. Created string `json:"created"`
  477. Model string `json:"model,omitempty"`
  478. Provider string `json:"provider,omitempty"`
  479. Parts []sessionShowPart `json:"parts"`
  480. }
  481. type sessionShowPart struct {
  482. Type string `json:"type"`
  483. // Text content
  484. Text string `json:"text,omitempty"`
  485. // Reasoning
  486. Thinking string `json:"thinking,omitempty"`
  487. StartedAt int64 `json:"started_at,omitempty"`
  488. FinishedAt int64 `json:"finished_at,omitempty"`
  489. // Tool call
  490. ToolCallID string `json:"tool_call_id,omitempty"`
  491. Name string `json:"name,omitempty"`
  492. Input string `json:"input,omitempty"`
  493. // Tool result
  494. Content string `json:"content,omitempty"`
  495. IsError bool `json:"is_error,omitempty"`
  496. MIMEType string `json:"mime_type,omitempty"`
  497. // Binary
  498. Size int64 `json:"size,omitempty"`
  499. // Image URL
  500. URL string `json:"url,omitempty"`
  501. Detail string `json:"detail,omitempty"`
  502. // Finish
  503. Reason string `json:"reason,omitempty"`
  504. Time int64 `json:"time,omitempty"`
  505. }
  506. func convertParts(parts []message.ContentPart) []sessionShowPart {
  507. result := make([]sessionShowPart, 0, len(parts))
  508. for _, part := range parts {
  509. switch p := part.(type) {
  510. case message.TextContent:
  511. result = append(result, sessionShowPart{
  512. Type: "text",
  513. Text: p.Text,
  514. })
  515. case message.ReasoningContent:
  516. result = append(result, sessionShowPart{
  517. Type: "reasoning",
  518. Thinking: p.Thinking,
  519. StartedAt: p.StartedAt,
  520. FinishedAt: p.FinishedAt,
  521. })
  522. case message.ToolCall:
  523. result = append(result, sessionShowPart{
  524. Type: "tool_call",
  525. ToolCallID: p.ID,
  526. Name: p.Name,
  527. Input: p.Input,
  528. })
  529. case message.ToolResult:
  530. result = append(result, sessionShowPart{
  531. Type: "tool_result",
  532. ToolCallID: p.ToolCallID,
  533. Name: p.Name,
  534. Content: p.Content,
  535. IsError: p.IsError,
  536. MIMEType: p.MIMEType,
  537. })
  538. case message.BinaryContent:
  539. result = append(result, sessionShowPart{
  540. Type: "binary",
  541. MIMEType: p.MIMEType,
  542. Size: int64(len(p.Data)),
  543. })
  544. case message.ImageURLContent:
  545. result = append(result, sessionShowPart{
  546. Type: "image_url",
  547. URL: p.URL,
  548. Detail: p.Detail,
  549. })
  550. case message.Finish:
  551. result = append(result, sessionShowPart{
  552. Type: "finish",
  553. Reason: string(p.Reason),
  554. Time: p.Time,
  555. })
  556. default:
  557. result = append(result, sessionShowPart{
  558. Type: "unknown",
  559. })
  560. }
  561. }
  562. return result
  563. }
  564. type sessionShowOutput struct {
  565. Meta sessionShowMeta `json:"meta"`
  566. Messages []sessionShowMessage `json:"messages"`
  567. }