
側欄 Use Case - Clean Architecture in TypeScript
購物車側欄第一階段的 API 處理完畢後,我們一樣需要重構為 Use Case 來保持架構的一致性,以及後續的可擴充性,我們會繼續使用 AI 來協力完成這個修改。
定義介面
開始修改之前,我們需要先在 Entity 和 Use Case 把我們預期的商業邏輯定義出來。
加入 src/entity/Cart.ts
這個檔案,實作以下內容。
1export class CartItem {
2 constructor(
3 public readonly id: number, // index in the cart
4 public readonly name: string,
5 public readonly unitPrice: number,
6 public readonly quantity: number,
7 ){}
8
9 get totalPrice(): number {
10 return this.unitPrice * this.quantity;
11 }
12}
13
14export class Cart {
15 private _items: CartItem[] = [];
16
17 constructor(
18 public readonly id: string
19 ) {}
20
21 get items(): CartItem[] {
22 return [...this._items];
23 }
24
25 updateItem(name: string, unitPrice: number, quantity: number): void {
26 const existingItemIndex = this._items.findIndex(item => item.name === name);
27 if (existingItemIndex >= 0) {
28 this._items[existingItemIndex] = new CartItem(existingItemIndex, name, unitPrice, quantity);
29 } else {
30 const newItem = new CartItem(this._items.length, name, unitPrice, quantity);
31 this._items.push(newItem);
32 }
33 }
34
35 removeItem(name: string): void {
36 const itemIndex = this._items.findIndex(item => item.name === name);
37 if (itemIndex >= 0) {
38 this._items.splice(itemIndex, 1);
39 for (let i = itemIndex; i < this._items.length; i++) {
40 this._items[i] = new CartItem(i, this._items[i].name, this._items[i].unitPrice, this._items[i].quantity);
41 }
42 }
43 }
44}
基本上只需要描述 Cart 的行為,細節的部分像是 GitHub Copilot 的補全機制在這邊可以很快的把剩下的行為實作出來,唯一需要注意的是在商品資料更新的行為有可能跟預期不同,補全後還是需要仔細確認。
接下來在 src/usecase/interface.ts
定義我們所需的 Repository 和 Presenter 到裡面。
1// ...
2
3export interface CartRepository {
4 byId(id: string): Promise<Cart>;
5}
6
7export interface CartPresenter {
8 addItem(item: CartItem): void;
9}
最後加入 src/usecase/getCart.ts
描述我們預期的功能。
1import { CartRepository, CartPresenter } from './interface';
2
3export class GetCartUsecase {
4 constructor(
5 private readonly cartRepository: CartRepository,
6 private readonly cartPresenter: CartPresenter,
7 ){}
8
9 async execute(id: string): Promise<void> {
10 const cart = await this.cartRepository.byId(id);
11 cart.items.forEach((item) => {
12 this.cartPresenter.addItem(item);
13 });
14 }
15}
進行重構
當我們必要的介面定義完畢後,就可以跟前面的處理一樣讓 AI 來把剩下的細節處理完,而且我們已經有 GetChatMessage
的範例當基準,使用的提示(Prompt)可以直接要求參考這些檔案。
以 Aider 為例子,這次會將 src/controller/cart.ts
、src/usecase/interface.ts
、src/usecase/getCart.ts
和 Entity 加入外,還會把 src/controller/chat.ts
、src/presenter/JsonChatMessagePresenter.ts
等檔案也加入參考,因此提示會變成像下面這樣。
1Reference to `src/controller/chat.ts` to refactor `src/controller/cart.ts` to use `GetCartUsecase`
2
3## CartRepository
4
5- Reference `KvChatMessageRepository` to implement `CartRepository` as `KvCartRepository`
6- Save in memory for now
7- Define `CartSchema` and `CartItemSchema` for saving in kv
8- Predefine mock data in repository for development purposes
9
10## CartPresenter
11
12- Reference `JsonChatMessagePresenter` to implement `CartPresenter` as `JsonCartPresenter`
13- Define `CartSchema` and `CartItemSchema` for presenting in json
14- Define `toJson` method to convert `Cart` to JSON
大多數時候我們的 Repository、Presenter 這類低階元件都會非常類似,以往會耗費大量的時間维護跟修改,以及從裡面抽離商業邏輯出來,但是透過 AI 就能把「類似但不規則」這樣的情況解決,而不需要人工去手動區分差異來维護。
工具使用
取得購物車內容的 Use Case 實作後,我們還可以把它轉換成 AI 可以使用的工具,我們先在 src/service/LlmAssistantService.ts
中調整原本生成訊息的實作,修改為以下版本。
1// ...
2
3const { text } = await generateText({
4 model: openai(this.env.OPENAI_MODEL),
5 system: `
6 You are a helpful assistant that can answer questions and provide information about the shopping cart. You can also retrieve the current contents of the cart when requested.
7 `,
8 messages: aiMessages,
9 maxSteps: 5,
10 tools: {
11 getCart: tool({
12 description: 'Get the current shopping cart contents',
13 parameters: z.object({}),
14 execute: async () => {
15 // TODO: use getCartUseCase
16 return { items: [] };
17 }
18 })
19 }
20});
21
22// ...
我們先簡單的加入 System Prompt 避免被 Tool Use 的資訊影響,並且定義一個尚未實作的工具。
不過 AssistantService
的設計無法知道目前對話的 ID 是多少,我們需要更新一下 src/usecase/interface.ts
和 src/usecase/chatWithAssistant.ts
加入可以識別對話的資訊。
1// ...
2export interface AssistantService {
3 ask(chatId: string, messages: ChatMessage[]): Promise<string>;
4}
1export class ChatWithAssistant {
2
3 // ...
4 async execute(id: string, message: string): Promise<void> {
5 // ...
6
7 await this.chatMessageRepository.save(id, newMessages);
8
9 newMessages.forEach((message) => {
10 this.chatMessagePresenter.addMessage(message);
11 });
12 }
13}
最後,讓 Aider 參考像是 src/controller/cart.ts
和 Use Case、Entity 等檔案,並且允許編輯 src/controller/chat.ts
加入新的依賴,透過以下提示要求重構。
1Update `LlmAssistantService` to implement `getCart` tool.
2
3- The `cartId` is same as `chatId`
4- Use `getCartUsecase` to retrieve the cart.
5- Add `ToolCartPresenter` to format the cart response, use `toTool()` method.
6- Use dependency injection to provide repository and presenter
7- Create a new usecase in tool handler
完成之後處理一些設計上的細節,現在我們就可以在 LLM 的視窗向 AI 提問購物車有的商品。在測試時要注意 LLM 模型對工具使用的支援狀況,如果使用 Ollama 測試會需要支援工具的模型才能順利呼叫。
除此之外, AI SDK 的 maxSteps
設定必須手動調整,大約在 5 ~ 15 的範圍即可,這是因為目前比較新的模型會自己使用工具到可以回答,如果只有預設的 1
會造成使用完工具就停止,而沒有任何訊息回傳。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 連載介紹 - Clean Architecture in TypeScript
- 目標設定 - Clean Architecture in TypeScript
- Hono 框架 - Clean Architecture in TypeScript
- tsyringe 套件 - Clean Architecture in TypeScript
- 專案設定 - Clean Architecture in TypeScript
- 介面規劃 - Clean Architecture in TypeScript
- 架構規劃 - Clean Architecture in TypeScript
- 助手對話介面 - Clean Architecture in TypeScript
- 對話紀錄 API - Clean Architecture in TypeScript
- 對話紀錄 UseCase - Clean Architecture in TypeScript
- 整合大型語言模型 - Clean Architecture in TypeScript
- 對話 UseCase - Clean Architecture in TypeScript
- 購物車側欄 - Clean Architecture in TypeScript
- 側欄 Use Case - Clean Architecture in TypeScript