File size: 5,675 Bytes
72606cb
 
 
 
 
 
 
 
 
 
 
916f8e7
 
deb62b3
916f8e7
 
 
 
 
 
 
deb62b3
 
 
 
 
 
 
 
9d6c362
 
 
 
deb62b3
916f8e7
 
13b41bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916f8e7
 
 
 
 
 
 
72606cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916f8e7
 
 
 
 
 
 
 
 
 
 
72606cb
 
 
 
 
 
 
 
 
 
 
 
 
13b41bf
72606cb
 
 
13b41bf
72606cb
13b41bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72606cb
 
 
916f8e7
 
 
 
 
 
 
 
 
 
 
 
72606cb
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import { lazy, Suspense } from "react";
import { ResultTable } from "./ResultTable";
import { DatabaseIcon, WindIcon } from "./icons";
import type { ConversationMessage } from "./types";

const AnalysisChart = lazy(() =>
  import("./AnalysisChart").then((module) => ({
    default: module.AnalysisChart,
  })),
);

const analysisLabels: Record<string, string> = {
  rank_cities: "City ranking",
  city_average: "City average",
  threshold_cities: "Threshold analysis",
  compare_cities: "City comparison",
  time_trend: "Time trend",
  relationship: "Relationship analysis",
  strongest_weather_relationship: "Weather-factor ranking",
  seasonal_profile: "Seasonal profile",
  funding_lookup: "NCAP funding",
  station_coverage: "Monitoring coverage",
  weekday_weekend_profile: "Weekday/weekend comparison",
  condition_comparison: "Threshold-group comparison",
  rank_states: "State ranking",
  coverage_trend: "Coverage trend",
  funding_rank: "NCAP funding ranking",
  ncap_threshold_cities: "NCAP pollution threshold",
  ncap_funding_groups: "NCAP funding groups",
  pollution_change: "Period change",
  context_relationship: "Context relationship",
  threshold_frequency: "Threshold frequency",
  analysis_unavailable: "Analysis needs refinement",
  composition: "Composed analysis",
};

function formatArgument(name: string, value: unknown) {
  if (Array.isArray(value)) {
    if (value.length === 0) {
      if (name === "months") return "all months";
      if (name === "cities") return "all cities";
      return "none";
    }
    return value.map((item) => String(item)).join(", ");
  }
  if (typeof value === "string") {
    return value.replaceAll("_", " ");
  }
  return String(value);
}

export function ChatMessage({
  message,
  onPrompt,
}: {
  message: ConversationMessage;
  onPrompt: (prompt: string) => void;
}) {
  if (message.role === "user") {
    return (
      <article className="message message--user">
        <div className="user-bubble">{message.content}</div>
      </article>
    );
  }

  return (
    <article className={`message message--assistant ${message.error ? "has-error" : ""}`}>
      <div className="assistant-avatar">
        <WindIcon />
      </div>
      <div className="assistant-body">
        <div className="assistant-meta">
          <strong>VayuChat</strong>
          <span>Gemini 3.5 Flash-Lite</span>
        </div>
        {message.error ? (
          <div className="answer-error" role="alert">
            <strong>Analysis stopped</strong>
            <p>{message.error}</p>
          </div>
        ) : (
          <>
            <p className="answer-lead">{message.result?.answer ?? message.content}</p>
            {message.result?.analysis_type ? (
              <div className="result-provenance" aria-label="Analysis provenance">
                <span>
                  <DatabaseIcon />
                  {analysisLabels[message.result.analysis_type] ?? "Validated analysis"}
                </span>
                {message.result.elapsed_ms > 0 ? (
                  <span>{(message.result.elapsed_ms / 1000).toFixed(1)}s</span>
                ) : null}
              </div>
            ) : null}
            {message.result?.key_points.length ? (
              <ul className="key-points">
                {message.result.key_points.map((point) => (
                  <li key={point}>{point}</li>
                ))}
              </ul>
            ) : null}
            {message.result ? (
              <Suspense fallback={<div className="chart-loading" aria-hidden="true" />}>
                <AnalysisChart result={message.result} />
              </Suspense>
            ) : null}
            {message.result ? <ResultTable result={message.result} /> : null}
            {message.result?.method_note || message.result?.analysis_steps.length ? (
              <details className="method-details">
                <summary>
                  <DatabaseIcon />
                  Analysis recipe
                </summary>
                {message.result.analysis_steps.length ? (
                  <ol className="analysis-recipe">
                    {message.result.analysis_steps.map((step, index) => (
                      <li key={`${step.function_name}-${index}`}>
                        <div className="recipe-function">
                          <span>{String(index + 1).padStart(2, "0")}</span>
                          <code>{step.function_name}</code>
                        </div>
                        <dl>
                          {Object.entries(step.arguments).map(([name, value]) => (
                            <div key={name}>
                              <dt>{name.replaceAll("_", " ")}</dt>
                              <dd>{formatArgument(name, value)}</dd>
                            </div>
                          ))}
                        </dl>
                      </li>
                    ))}
                  </ol>
                ) : null}
                {message.result.method_note ? <p>{message.result.method_note}</p> : null}
              </details>
            ) : null}
            {message.result?.suggested_questions.length ? (
              <section className="follow-ups" aria-label="Suggested follow-up questions">
                <p>Ask a follow-up</p>
                <div>
                  {message.result.suggested_questions.map((question) => (
                    <button key={question} onClick={() => onPrompt(question)}>
                      {question}
                    </button>
                  ))}
                </div>
              </section>
            ) : null}
          </>
        )}
      </div>
    </article>
  );
}