1import {
2  ChangeDetectionStrategy,
3  Component,
4  computed,
5  input,
6} from "@angular/core";
7import { registerFrontendTool } from "@copilotkit/angular";
8import type { AngularToolCall } from "@copilotkit/angular";
9import { z } from "zod";
10
11import { FeatureHeaderComponent } from "../feature-header.component";
12import { HeadlessChatController } from "./headless-chat";
13import type { ShowcaseMessage } from "./headless-chat.types";
14import { messageText, toolArguments } from "./headless-message-utils";
15
16@Component({
17  selector: "showcase-headless-weather-card",
18  changeDetection: ChangeDetectionStrategy.OnPush,
19  host: { class: "headless-tool-card", "data-testid": "headless-weather-card" },
20  template: `
21    <span>Weather</span><strong>{{ location() }}</strong>
22    <p>22°C · Partly cloudy</p>
23  `,
24})
25export class HeadlessWeatherCard {
26  readonly toolCall =
27    input.required<AngularToolCall<{ location?: string; city?: string }>>();
28  protected readonly location = computed(
29    () => this.toolCall().args.location ?? this.toolCall().args.city ?? "Tokyo",
30  );
31}
32
33@Component({
34  selector: "showcase-headless-stock-card",
35  changeDetection: ChangeDetectionStrategy.OnPush,
36  host: { class: "headless-tool-card", "data-testid": "headless-stock-card" },
37  template: `
38    <span>Stock price</span><strong>{{ toolCall().args.ticker ?? "AAPL" }}</strong>
39    <p>$189.42 · +1.27%</p>
40  `,
41})
42export class HeadlessStockCard {
43  readonly toolCall = input.required<AngularToolCall<{ ticker?: string }>>();
44}
45
46@Component({
47  selector: "showcase-headless-highlight-card",
48  changeDetection: ChangeDetectionStrategy.OnPush,
49  host: {
50    class: "headless-tool-card highlight",
51    "data-testid": "headless-highlight-card",
52  },
53  template: `
54    <span>Highlighted note</span
55    ><strong>{{ toolCall().args.text ?? "Note" }}</strong>
56  `,
57})
58export class HeadlessHighlightCard {
59  readonly toolCall =
60    input.required<AngularToolCall<{ text?: string; color?: string }>>();
61}
62
63@Component({
64  selector: "showcase-headless-revenue-card",
65  changeDetection: ChangeDetectionStrategy.OnPush,
66  host: {
67    class: "headless-tool-card",
68    "data-testid": "headless-revenue-chart",
69  },
70  template: `
71    <span>Revenue</span><strong>Six-month revenue</strong>
72    <div class="mini-bars" aria-label="Revenue increased over six months">
73      @for (height of bars; track $index) {
74        <i [style.height.%]="height"></i>
75      }
76    </div>
77  `,
78})
79export class HeadlessRevenueCard {
80  readonly toolCall = input.required<AngularToolCall>();
81  protected readonly bars = [32, 45, 51, 63, 74, 92];
82}
83
84@Component({
85  selector: "showcase-headless-complete-feature",
86  imports: [
87    FeatureHeaderComponent,
88    HeadlessWeatherCard,
89    HeadlessStockCard,
90    HeadlessHighlightCard,
91    HeadlessRevenueCard,
92  ],
93  changeDetection: ChangeDetectionStrategy.OnPush,
94  host: { class: "feature-page" },
95  template: `
96    <showcase-feature-header />
97    <main class="headless-page" aria-label="Complete headless chat">
98      <section
99        class="headless-panel"
100        data-testid="copilot-chat"
101        [attr.data-copilot-running]="isRunning()"
102      >
103        <h2>Headless Chat (Complete)</h2>
104        <div class="headless-messages" aria-live="polite">
105          @for (message of messages(); track message.id) {
106            @if (message.role === "user") {
107              <div class="headless-user-message" data-message-role="user">
108                {{ messageText(message) }}
109              </div>
110            } @else if (message.role === "assistant") {
111              <div
112                class="headless-assistant-message"
113                data-testid="headless-message-assistant"
114                data-message-role="assistant"
115              >
116                @if (messageText(message); as content) {
117                  <p>{{ content }}</p>
118                }
119                @for (call of message.toolCalls ?? []; track call.id) {
120                  @switch (call.function.name) {
121                    @case ("get_weather") {
122                      <showcase-headless-weather-card [toolCall]="toolCall(call)" />
123                    }
124                    @case ("get_stock_price") {
125                      <showcase-headless-stock-card [toolCall]="toolCall(call)" />
126                    }
127                    @case ("highlight_note") {
128                      <showcase-headless-highlight-card
129                        [toolCall]="toolCall(call)"
130                      />
131                    }
132                    @case ("get_revenue_chart") {
133                      <showcase-headless-revenue-card [toolCall]="toolCall(call)" />
134                    }
135                  }
136                }
137              </div>
138            }
139          }
140        </div>
141        <div class="headless-suggestions">
142          @for (suggestion of suggestions; track suggestion.label) {
143            <button
144              type="button"
145              [disabled]="isRunning()"
146              (click)="send(suggestion.prompt)"
147            >
148              {{ suggestion.label }}
149            </button>
150          }
151        </div>
152        @if (error()) {
153          <p class="headless-error" role="alert">{{ error() }}</p>
154        }
155        <div class="headless-composer">
156          <textarea
157            rows="2"
158            aria-label="Message"
159            [value]="inputValue()"
160            (input)="updateInput($event)"
161            (keydown)="handleComposerKeydown($event)"
162          ></textarea>
163          <button
164            type="button"
165            [disabled]="isRunning() || !inputValue().trim()"
166            (click)="send()"
167          >
168            Send
169          </button>
170        </div>
171      </section>
172    </main>
173  `,
174})
175export class HeadlessCompleteFeatureComponent extends HeadlessChatController {
176  protected readonly messageText = messageText;
177  protected readonly suggestions = [
178    { label: "Weather", prompt: "What's the weather in Tokyo?" },
179    { label: "Stock price", prompt: "What's the price of AAPL right now?" },
180    {
181      label: "Highlight a note",
182      prompt: "Highlight this note for me: 'ship the demo on Friday'.",
183    },
184    {
185      label: "Revenue chart",
186      prompt: "Show me a chart of revenue over the last six months.",
187    },
188  ] as const;
189
190  constructor() {
191    super("headless-complete");
192    registerFrontendTool({
193      name: "highlight_note",
194      description: "Highlight a short note or phrase in the chat.",
195      parameters: z.object({
196        text: z.string(),
197        color: z.string().optional(),
198      }),
199      followUp: false,
200      handler: async ({ text, color }) => ({ text, color: color ?? "yellow" }),
201    });
202  }
203
204  protected toolCall(
205    call: NonNullable<ShowcaseMessage["toolCalls"]>[number],
206  ): AngularToolCall {
207    return {
208      name: call.function.name,
209      args: toolArguments(call.function.arguments),
210      status: "executing",
211      result: undefined,
212    };
213  }
214}
215
b203d8f70
CopilotKit Showcase