ChatKit에서의 액션

ChatKit에서의 액션 (Actions in ChatKit)

액션은 사용자가 메시지를 제출하지 않아도 ChatKit SDK 프론트엔드가 스트리밍 응답을 트리거하는 방법이에요. ChatKit SDK 밖에서 부수 효과(side-effect)를 트리거하는 데도 쓸 수 있어요.

출처: 문서

본문

액션 트리거하기

위젯과의 사용자 상호작용에 대한 응답으로

액션은 이를 지원하는 위젯 노드에 ActionConfig를 붙이면 트리거할 수 있어요. 예를 들어 Button의 클릭 이벤트에 반응할 수 있어요. 사용자가 이 버튼을 클릭하면 액션이 서버로 전송되고, 서버에서 위젯을 업데이트하거나 추론을 실행하거나 새 스레드 항목을 스트리밍하는 등의 작업을 할 수 있어요.

button = Button(
    label="Example",
    onClickAction=ActionConfig(
        type="example",
        payload={"id": 123},
    ),
)

액션은 프론트엔드에서 sendAction()으로 직접 명령적으로 보낼 수도 있어요. ChatKit 밖에서 일어나는 상호작용에 ChatKit이 응답해야 할 때 가장 유용하겠지만, 클라이언트와 서버 양쪽에서 응답해야 할 때 액션을 체이닝하는 데도 쓸 수 있어요.

await chatKit.sendAction({
  type: "example",
  payload: { id: 123 },
});

액션 처리하기

서버에서

기본적으로 액션은 서버로 전송돼요. ChatKitServer에 action 메서드를 구현하면 서버에서 액션을 처리할 수 있어요.

class MyChatKitServer(ChatKitServer[RequestContext]):
    async def action(
        self,
        thread: ThreadMetadata,
        action: Action[str, Any],
        sender: WidgetItem | None,
        context: RequestContext,
    ) -> AsyncIterator[Event]:
        if action.type == "example":
            await do_thing(action.payload["id"])

            # Often you'll want to add a HiddenContextItem so the model
            # can see that the user did something.
            await self.store.add_thread_item(
                thread.id,
                HiddenContextItem(
                    id="item_123",
                    thread_id=thread.id,
                    created_at=datetime.now(),
                    content="<USER_ACTION>The user did a thing</USER_ACTION>",
                ),
                context,
            )

            # Then you might want to run inference to stream a response
            # back to the user.
            async for event in self.generate(context, thread):
                yield event

액션과 그 payload는 클라이언트가 서버로 보내는 것이므로 신뢰할 수 없는 데이터로 취급하세요.

클라이언트에서

때로는 클라이언트 통합에서 액션을 처리하고 싶을 때가 있어요. 그러려면 ActionConfig에 handler="client"를 추가해서 액션을 클라이언트 측 액션 핸들러로 보내야 한다고 지정하면 돼요.

button = Button(
    label="Example",
    onClickAction=ActionConfig(type="example", payload={"id": 123}, handler="client"),
)

이제 액션이 트리거되면 ChatKit을 인스턴스화할 때 제공한 콜백으로 전달돼요.

async function handleWidgetAction(action) {
  if (action.type === "example") {
    const res = await doSomething(action);

    // You can fire off actions to your server from here as well.
    // For example, stream new thread items or update a widget.
    await chatKit.sendAction({
      type: "example_complete",
      payload: res,
    });
  }
}

chatKit.setOptions({
  // Other options...
  widgets: { onAction: handleWidgetAction },
});

강한 타입의 액션 (Strongly typed actions)

기본적으로 Action과 ActionConfig는 강한 타입이 아니에요. 그러나 Action에 create 헬퍼를 노출해서 강한 타입 액션 집합에서 ActionConfig를 생성할 수 있어요.

class ExamplePayload(BaseModel):
    id: int


ExampleAction = Action[Literal["example"], ExamplePayload]
OtherAction = Action[Literal["other"], None]

AppAction = Annotated[
    ExampleAction | OtherAction,
    Field(discriminator="type"),
]

ActionAdapter: TypeAdapter[AppAction] = TypeAdapter(AppAction)


def parse_app_action(action: Action[str, Any]) -> AppAction:
    return ActionAdapter.validate_python(action)


# Usage in a widget
# Action provides a create helper which makes it easy to generate
# ActionConfigs from strongly typed actions.
button = Button(
    label="Example",
    onClickAction=ExampleAction.create(ExamplePayload(id=123)),
)


# usage in action handler
class MyChatKitServer(ChatKitServer[RequestContext]):
    async def action(
        self,
        thread: ThreadMetadata,
        action: Action[str, Any],
        sender: WidgetItem | None,
        context: RequestContext,
    ) -> AsyncIterator[Event]:
        # add custom error handling if needed
        app_action = parse_app_action(action)
        if app_action.type == "example":
            await do_thing(app_action.payload.id)
            yield ThreadItemDoneEvent(
                item=AssistantMessageItem(
                    id=self.store.generate_item_id("message", thread, context),
                    thread_id=thread.id,
                    created_at=datetime.now(),
                    content=[AssistantMessageContent(text="Action complete.")],
                )
            )

위젯과 액션으로 커스텀 폼 만들기

사용자 입력을 받는 위젯 노드가 Form 안에 마운트되면, 그 필드들의 값이 Form 안에서 시작된 모든 액션의 payload에 포함돼요.

Form 값은 payload에서 name으로 키가 매겨져요. 예를 들어:

  • Select(name="title") → action.payload.title
  • Select(name="todo.title") → action.payload.todo.title
form = Form(
    direction="col",
    validation="native",
    onSubmitAction=ActionConfig(
        type="update_todo",
        payload={"id": todo.id},
    ),
    children=[
        Title(value="Edit Todo"),
        Text(value="Title", color="secondary", size="sm"),
        Text(
            value=todo.title,
            editable=EditableProps(name="title", required=True),
        ),
        Text(value="Description", color="secondary", size="sm"),
        Text(
            value=todo.description,
            editable=EditableProps(name="description"),
        ),
        Button(label="Save", submit=True),
    ],
)


class MyChatKitServer(ChatKitServer[RequestContext]):
    async def action(
        self,
        thread: ThreadMetadata,
        action: Action[str, Any],
        sender: WidgetItem | None,
        context: RequestContext,
    ) -> AsyncIterator[Event]:
        if action.type == "update_todo":
            todo_id = action.payload["id"]
            # Any action that originates from within the Form will
            # include title and description.
            title = action.payload["title"]
            description = action.payload["description"]

            await update_todo(todo_id, title, description)
            yield ThreadItemDoneEvent(
                item=AssistantMessageItem(
                    id=self.store.generate_item_id("message", thread, context),
                    thread_id=thread.id,
                    created_at=datetime.now(),
                    content=[AssistantMessageContent(text="Todo updated.")],
                )
            )

검증 (Validation)

Form은 기본적인 기본(native) 폼 검증을 사용해요. required와 pattern을 설정한 필드에 적용하고, 폼에 유효하지 않은 필드가 있으면 제출을 막아요.

향후 더 나은 UX, 더 표현력 있는 검증, 커스텀 오류 표시 등을 가진 새로운 검증 모드를 추가할 수도 있어요. 그 전까지는 위젯이 까다로운 검증이 필요한 복잡한 폼에는 좋은 매체가 아니에요. 이런 필요가 있다면, 클라이언트 측 액션 처리를 써서 모달을 띄우고 거기서 커스텀 폼을 보여준 뒤, 결과를 sendAction으로 ChatKit에 다시 전달하는 패턴이 더 좋아요.

Card를 Form으로 취급하기

Card에 asForm=True를 전달하면 Form처럼 동작해요. 검증을 실행하고 수집한 필드를 Card의 confirm 액션에 전달해요.

Payload 키 충돌

payload의 다른 기존 사전 정의 키와 이름이 충돌하면 폼 값은 무시돼요. 이건 아마 버그라서, 그런 경우 error 이벤트를 내보낼 거예요.

위젯의 로딩 상태 상호작용 제어하기

ActionConfig.loadingBehavior를 사용해 액션이 위젯에서 로딩 상태를 어떻게 트리거할지 제어할 수 있어요.

button = Button(
    label="This may take a while...",
    onClickAction=ActionConfig(
        type="long_running_action_that_should_block_other_ui_interactions",
        loadingBehavior="container",
    ),
)
값 동작
auto 액션이 사용되는 방식에 따라 적응해요. (기본값)
self 액션이 바인딩된 위젯 노드에 로딩 상태를 트리거해요.
container 액션이 전체 위젯 컨테이너에 로딩 상태를 트리거해요. 위젯이 약간 흐려지고 비활성이 돼요.
none 로딩 상태 없음

auto 동작 사용하기

일반적으로 기본값인 auto를 권장해요. auto는 액션이 바인딩된 위치에 따라 로딩 상태를 트리거해요. 예를 들어:

  • Button.onClickAction → self
  • Select.onChangeAction → none
  • Card.confirm.action → container

더 알아보기 (Learn more)