feat: preview card with Confirm/Cancel buttons before Planner writes
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s
This commit is contained in:
parent
59c6814ccc
commit
59a983133f
@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CardFactory,
|
|
||||||
CloudAdapter,
|
CloudAdapter,
|
||||||
ConversationState,
|
ConversationState,
|
||||||
StatePropertyAccessor,
|
StatePropertyAccessor,
|
||||||
@ -11,8 +10,8 @@ import { UserTokenClient } from "botframework-connector";
|
|||||||
import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs";
|
import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs";
|
||||||
import { createGraphClient } from "../graph/graphClientFactory";
|
import { createGraphClient } from "../graph/graphClientFactory";
|
||||||
import { PlannerClient } from "../graph/plannerClient";
|
import { PlannerClient } from "../graph/plannerClient";
|
||||||
import { LlmClassifier } from "../llm/types";
|
import { ClassifiedAction, LlmClassifier } from "../llm/types";
|
||||||
import { buildResultCard } from "../cards/confirmationCard";
|
import { buildClarificationCard, buildPreviewCard, buildResultCard } from "../cards/confirmationCard";
|
||||||
|
|
||||||
async function signOutUser(context: TurnContext, connectionName: string): Promise<void> {
|
async function signOutUser(context: TurnContext, connectionName: string): Promise<void> {
|
||||||
const adapter = context.adapter as CloudAdapter;
|
const adapter = context.adapter as CloudAdapter;
|
||||||
@ -32,6 +31,23 @@ async function signOutUser(context: TurnContext, connectionName: string): Promis
|
|||||||
const OAUTH_PROMPT = "graphOAuthPrompt";
|
const OAUTH_PROMPT = "graphOAuthPrompt";
|
||||||
const MAIN_DIALOG = "mainDialog";
|
const MAIN_DIALOG = "mainDialog";
|
||||||
|
|
||||||
|
interface ActionContextSnapshot {
|
||||||
|
planTitle?: string;
|
||||||
|
bucketTitle?: string;
|
||||||
|
taskTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingAction {
|
||||||
|
action: Exclude<ClassifiedAction, { type: "ask_clarification" }>;
|
||||||
|
ctx: ActionContextSnapshot;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MainDialogOpts =
|
||||||
|
| { kind: "utterance"; text: string }
|
||||||
|
| { kind: "confirm" }
|
||||||
|
| { kind: "cancel" };
|
||||||
|
|
||||||
export interface PlannerBotDeps {
|
export interface PlannerBotDeps {
|
||||||
conversationState: ConversationState;
|
conversationState: ConversationState;
|
||||||
userState: UserState;
|
userState: UserState;
|
||||||
@ -45,6 +61,7 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
private readonly classifier: LlmClassifier;
|
private readonly classifier: LlmClassifier;
|
||||||
private readonly dialogs: DialogSet;
|
private readonly dialogs: DialogSet;
|
||||||
private readonly dialogStateAccessor: StatePropertyAccessor;
|
private readonly dialogStateAccessor: StatePropertyAccessor;
|
||||||
|
private readonly pendingAccessor: StatePropertyAccessor<PendingAction | undefined>;
|
||||||
private readonly connectionName: string;
|
private readonly connectionName: string;
|
||||||
|
|
||||||
constructor(deps: PlannerBotDeps) {
|
constructor(deps: PlannerBotDeps) {
|
||||||
@ -52,9 +69,10 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
this.conversationState = deps.conversationState;
|
this.conversationState = deps.conversationState;
|
||||||
this.userState = deps.userState;
|
this.userState = deps.userState;
|
||||||
this.classifier = deps.classifier;
|
this.classifier = deps.classifier;
|
||||||
|
|
||||||
this.connectionName = deps.connectionName;
|
this.connectionName = deps.connectionName;
|
||||||
|
|
||||||
this.dialogStateAccessor = this.conversationState.createProperty("DialogState");
|
this.dialogStateAccessor = this.conversationState.createProperty("DialogState");
|
||||||
|
this.pendingAccessor = this.conversationState.createProperty<PendingAction | undefined>("PendingAction");
|
||||||
this.dialogs = new DialogSet(this.dialogStateAccessor);
|
this.dialogs = new DialogSet(this.dialogStateAccessor);
|
||||||
|
|
||||||
this.dialogs.add(
|
this.dialogs.add(
|
||||||
@ -75,18 +93,37 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
await step.context.sendActivity("로그인이 완료되지 않았어요. 다시 시도해 주세요.");
|
await step.context.sendActivity("로그인이 완료되지 않았어요. 다시 시도해 주세요.");
|
||||||
return step.endDialog();
|
return step.endDialog();
|
||||||
}
|
}
|
||||||
const utterance: string = (step.options as { utterance: string }).utterance;
|
const opts = step.options as MainDialogOpts;
|
||||||
await this.handleUtterance(step.context, tokenResponse.token, utterance);
|
if (opts.kind === "utterance") {
|
||||||
|
await this.handleUtterance(step.context, tokenResponse.token, opts.text);
|
||||||
|
} else if (opts.kind === "confirm") {
|
||||||
|
await this.handleConfirm(step.context, tokenResponse.token);
|
||||||
|
} else {
|
||||||
|
await this.handleCancel(step.context);
|
||||||
|
}
|
||||||
return step.endDialog();
|
return step.endDialog();
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.onMessage(async (context, next) => {
|
this.onMessage(async (context, next) => {
|
||||||
|
const value = context.activity.value as { kind?: string } | undefined;
|
||||||
const text = (context.activity.text ?? "").trim();
|
const text = (context.activity.text ?? "").trim();
|
||||||
|
|
||||||
|
// Adaptive Card button click → dispatch by `kind`
|
||||||
|
if (value && (value.kind === "confirm" || value.kind === "cancel")) {
|
||||||
|
const dc = await this.dialogs.createContext(context);
|
||||||
|
const r = await dc.continueDialog();
|
||||||
|
if (r.status === DialogTurnStatus.empty) {
|
||||||
|
await dc.beginDialog(MAIN_DIALOG, { kind: value.kind } as MainDialogOpts);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (text.toLowerCase() === "logout") {
|
if (text.toLowerCase() === "logout") {
|
||||||
await signOutUser(context, this.connectionName);
|
await signOutUser(context, this.connectionName);
|
||||||
|
await this.pendingAccessor.set(context, undefined);
|
||||||
await context.sendActivity("로그아웃 완료.");
|
await context.sendActivity("로그아웃 완료.");
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
@ -100,14 +137,12 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
const dialogContext = await this.dialogs.createContext(context);
|
const dialogContext = await this.dialogs.createContext(context);
|
||||||
const result = await dialogContext.continueDialog();
|
const result = await dialogContext.continueDialog();
|
||||||
if (result.status === DialogTurnStatus.empty) {
|
if (result.status === DialogTurnStatus.empty) {
|
||||||
await dialogContext.beginDialog(MAIN_DIALOG, { utterance: text });
|
await dialogContext.beginDialog(MAIN_DIALOG, { kind: "utterance", text } as MainDialogOpts);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Teams sends an invoke activity ("signin/verifyState" or "signin/tokenExchange")
|
// Token-response event (legacy bot-framework token delivery path)
|
||||||
// after the user completes the OAuth flow. We must route it back into the
|
|
||||||
// active dialog so OAuthPrompt can finish.
|
|
||||||
this.onTokenResponseEvent(async (context, next) => {
|
this.onTokenResponseEvent(async (context, next) => {
|
||||||
const dialogContext = await this.dialogs.createContext(context);
|
const dialogContext = await this.dialogs.createContext(context);
|
||||||
await dialogContext.continueDialog();
|
await dialogContext.continueDialog();
|
||||||
@ -117,7 +152,8 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
this.onMembersAdded(async (context, next) => {
|
this.onMembersAdded(async (context, next) => {
|
||||||
const greeting =
|
const greeting =
|
||||||
"안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" +
|
"안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" +
|
||||||
"예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”";
|
"예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”\n" +
|
||||||
|
"작업을 실제로 반영하기 전에 항상 확인 카드를 보여드려요.";
|
||||||
for (const m of context.activity.membersAdded ?? []) {
|
for (const m of context.activity.membersAdded ?? []) {
|
||||||
if (m.id !== context.activity.recipient.id) {
|
if (m.id !== context.activity.recipient.id) {
|
||||||
await context.sendActivity(greeting);
|
await context.sendActivity(greeting);
|
||||||
@ -133,6 +169,7 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
await this.userState.saveChanges(context, false);
|
await this.userState.saveChanges(context, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teams sign-in invoke activities — route back into the active OAuth dialog.
|
||||||
protected async handleTeamsSigninVerifyState(context: TurnContext): Promise<void> {
|
protected async handleTeamsSigninVerifyState(context: TurnContext): Promise<void> {
|
||||||
const dialogContext = await this.dialogs.createContext(context);
|
const dialogContext = await this.dialogs.createContext(context);
|
||||||
await dialogContext.continueDialog();
|
await dialogContext.continueDialog();
|
||||||
@ -143,6 +180,8 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
await dialogContext.continueDialog();
|
await dialogContext.continueDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Classify the utterance and present a Confirm/Cancel preview card. Nothing
|
||||||
|
* is written to Planner here — the user has to click ✅ on the card. */
|
||||||
private async handleUtterance(
|
private async handleUtterance(
|
||||||
context: TurnContext,
|
context: TurnContext,
|
||||||
token: string,
|
token: string,
|
||||||
@ -170,8 +209,6 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// listPlansWithBuckets already drops "Deprecated" plans; mirror that for tasks
|
|
||||||
// so the LLM can't try to update a task in a hidden plan.
|
|
||||||
const validPlanIds = new Set(plans.map((p) => p.planId));
|
const validPlanIds = new Set(plans.map((p) => p.planId));
|
||||||
recentTasks = recentTasks.filter((t) => validPlanIds.has(t.planId));
|
recentTasks = recentTasks.filter((t) => validPlanIds.has(t.planId));
|
||||||
|
|
||||||
@ -182,52 +219,71 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
nowIso: new Date().toISOString(),
|
nowIso: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (action.type === "ask_clarification") {
|
||||||
|
await this.pendingAccessor.set(context, undefined);
|
||||||
|
await context.sendActivity({ attachments: [buildClarificationCard(action.question)] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a human-readable context snapshot so the preview/result cards can
|
||||||
|
// show plan/bucket/task names instead of opaque IDs.
|
||||||
|
const ctx: ActionContextSnapshot = {};
|
||||||
|
if (action.type === "create_task") {
|
||||||
|
const plan = plans.find((p) => p.planId === action.planId);
|
||||||
|
ctx.planTitle = plan?.planTitle;
|
||||||
|
ctx.bucketTitle = plan?.buckets.find((b) => b.bucketId === action.bucketId)?.bucketTitle;
|
||||||
|
} else {
|
||||||
|
const task = recentTasks.find((t) => t.taskId === action.taskId);
|
||||||
|
ctx.taskTitle = task?.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pendingAccessor.set(context, { action, ctx, createdAt: Date.now() });
|
||||||
|
|
||||||
|
await context.sendActivity({ attachments: [buildPreviewCard({ action, ctx })] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User clicked ✅ on a preview card — execute the stored action. */
|
||||||
|
private async handleConfirm(context: TurnContext, token: string): Promise<void> {
|
||||||
|
const pending = await this.pendingAccessor.get(context);
|
||||||
|
if (!pending) {
|
||||||
|
await context.sendActivity("확정할 작업이 없어요. 다시 말씀해 주시겠어요?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.sendActivity({ type: "typing" });
|
||||||
|
const planner = new PlannerClient(createGraphClient(token));
|
||||||
|
const { action, ctx } = pending;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action.type === "create_task") {
|
if (action.type === "create_task") {
|
||||||
const { taskId } = await planner.createTask(action);
|
await planner.createTask(action);
|
||||||
const plan = plans.find((p) => p.planId === action.planId);
|
} else {
|
||||||
const bucket = plan?.buckets.find((b) => b.bucketId === action.bucketId);
|
|
||||||
await context.sendActivity({
|
|
||||||
attachments: [
|
|
||||||
buildResultCard({
|
|
||||||
action,
|
|
||||||
planTitle: plan?.planTitle,
|
|
||||||
bucketTitle: bucket?.bucketTitle,
|
|
||||||
status: "done",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
void taskId;
|
|
||||||
} else if (action.type === "update_task") {
|
|
||||||
await planner.updateTask(action);
|
await planner.updateTask(action);
|
||||||
if (action.appendNote) {
|
if (action.appendNote) {
|
||||||
await planner.appendTaskNote(action.taskId, action.appendNote);
|
await planner.appendTaskNote(action.taskId, action.appendNote);
|
||||||
}
|
}
|
||||||
const task = recentTasks.find((t) => t.taskId === action.taskId);
|
|
||||||
await context.sendActivity({
|
|
||||||
attachments: [
|
|
||||||
buildResultCard({
|
|
||||||
action,
|
|
||||||
taskTitle: task?.title,
|
|
||||||
status: "done",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await context.sendActivity({
|
|
||||||
attachments: [buildResultCard({ action, status: "ask" })],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
await this.pendingAccessor.set(context, undefined);
|
||||||
|
await context.sendActivity({ attachments: [buildResultCard({ action, ctx, status: "done" })] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await context.sendActivity({
|
await context.sendActivity({
|
||||||
attachments: [
|
attachments: [
|
||||||
buildResultCard({
|
buildResultCard({ action, ctx, status: "error", errorMessage: (err as Error).message }),
|
||||||
action,
|
|
||||||
status: "error",
|
|
||||||
errorMessage: (err as Error).message,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User clicked ❌ on a preview card. */
|
||||||
|
private async handleCancel(context: TurnContext): Promise<void> {
|
||||||
|
const pending = await this.pendingAccessor.get(context);
|
||||||
|
if (!pending) {
|
||||||
|
await context.sendActivity("취소할 작업이 없어요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.pendingAccessor.set(context, undefined);
|
||||||
|
await context.sendActivity({
|
||||||
|
attachments: [buildResultCard({ action: pending.action, ctx: pending.ctx, status: "canceled" })],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +1,113 @@
|
|||||||
import { CardFactory, Attachment } from "botbuilder";
|
import { CardFactory, Attachment } from "botbuilder";
|
||||||
import { ClassifiedAction } from "../llm/types";
|
import { ClassifiedAction } from "../llm/types";
|
||||||
|
|
||||||
export function buildResultCard(args: {
|
interface ActionContext {
|
||||||
action: ClassifiedAction;
|
|
||||||
planTitle?: string;
|
planTitle?: string;
|
||||||
bucketTitle?: string;
|
bucketTitle?: string;
|
||||||
taskTitle?: string;
|
taskTitle?: string;
|
||||||
status: "done" | "ask" | "error";
|
}
|
||||||
errorMessage?: string;
|
|
||||||
}): Attachment {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const a = args.action;
|
|
||||||
|
|
||||||
if (args.status === "error") {
|
function actionDetailLines(a: ClassifiedAction, ctx: ActionContext): string[] {
|
||||||
lines.push(`❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`);
|
const lines: string[] = [];
|
||||||
} else if (a.type === "create_task") {
|
if (a.type === "create_task") {
|
||||||
lines.push(`✅ 새 작업 생성`);
|
lines.push(`Plan: ${ctx.planTitle ?? a.planId}`);
|
||||||
lines.push(`Plan: ${args.planTitle ?? a.planId}`);
|
lines.push(`Bucket: ${ctx.bucketTitle ?? a.bucketId}`);
|
||||||
lines.push(`Bucket: ${args.bucketTitle ?? a.bucketId}`);
|
|
||||||
lines.push(`제목: ${a.title}`);
|
lines.push(`제목: ${a.title}`);
|
||||||
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
|
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
|
||||||
if (a.progress) lines.push(`진행: ${a.progress}`);
|
if (a.progress) lines.push(`진행: ${progressLabel(a.progress)}`);
|
||||||
} else if (a.type === "update_task") {
|
} else if (a.type === "update_task") {
|
||||||
lines.push(`🔄 작업 업데이트`);
|
lines.push(`대상: ${ctx.taskTitle ?? a.taskId}`);
|
||||||
lines.push(`대상: ${args.taskTitle ?? a.taskId}`);
|
if (a.newTitle) lines.push(`새 제목: ${a.newTitle}`);
|
||||||
if (a.progress) lines.push(`진행: ${a.progress}`);
|
if (a.progress) lines.push(`진행: ${progressLabel(a.progress)}`);
|
||||||
if (a.percentComplete !== undefined) lines.push(`진행률: ${a.percentComplete}%`);
|
if (a.percentComplete !== undefined) lines.push(`진행률: ${a.percentComplete}%`);
|
||||||
if (a.appendNote) lines.push(`메모: ${a.appendNote}`);
|
if (a.appendNote) lines.push(`메모 추가: ${a.appendNote}`);
|
||||||
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
|
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
|
||||||
} else {
|
} else if (a.type === "ask_clarification") {
|
||||||
lines.push(`❓ 한 번 더 확인이 필요해요`);
|
|
||||||
lines.push(a.question);
|
lines.push(a.question);
|
||||||
}
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
const card = {
|
function progressLabel(p: string): string {
|
||||||
|
switch (p) {
|
||||||
|
case "notStarted": return "시작 전";
|
||||||
|
case "inProgress": return "진행 중";
|
||||||
|
case "completed": return "완료";
|
||||||
|
default: return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview card with Confirm / Cancel buttons. Used before any Planner write. */
|
||||||
|
export function buildPreviewCard(args: {
|
||||||
|
action: Exclude<ClassifiedAction, { type: "ask_clarification" }>;
|
||||||
|
ctx: ActionContext;
|
||||||
|
}): Attachment {
|
||||||
|
const a = args.action;
|
||||||
|
const heading =
|
||||||
|
a.type === "create_task" ? "📋 새 작업을 만들까요?" : "🔄 이 작업을 업데이트할까요?";
|
||||||
|
const details = actionDetailLines(a, args.ctx);
|
||||||
|
|
||||||
|
return CardFactory.adaptiveCard({
|
||||||
type: "AdaptiveCard",
|
type: "AdaptiveCard",
|
||||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
version: "1.4",
|
version: "1.4",
|
||||||
body: [
|
body: [
|
||||||
{
|
{ type: "TextBlock", text: heading, weight: "Bolder", size: "Medium", wrap: true },
|
||||||
type: "TextBlock",
|
...details.map((t) => ({ type: "TextBlock", text: t, wrap: true, spacing: "Small" })),
|
||||||
text: lines[0],
|
{ type: "TextBlock", text: "아래 버튼으로 결정해 주세요.", isSubtle: true, wrap: true, spacing: "Medium" },
|
||||||
weight: "Bolder",
|
|
||||||
size: "Medium",
|
|
||||||
wrap: true,
|
|
||||||
},
|
|
||||||
...lines.slice(1).map((t) => ({
|
|
||||||
type: "TextBlock",
|
|
||||||
text: t,
|
|
||||||
wrap: true,
|
|
||||||
spacing: "Small",
|
|
||||||
})),
|
|
||||||
],
|
],
|
||||||
};
|
actions: [
|
||||||
|
{ type: "Action.Submit", title: "✅ 확인", data: { kind: "confirm" } },
|
||||||
return CardFactory.adaptiveCard(card);
|
{ type: "Action.Submit", title: "❌ 취소", data: { kind: "cancel" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result card after a Planner write succeeds/fails. */
|
||||||
|
export function buildResultCard(args: {
|
||||||
|
action: ClassifiedAction;
|
||||||
|
ctx?: ActionContext;
|
||||||
|
status: "done" | "error" | "canceled";
|
||||||
|
errorMessage?: string;
|
||||||
|
}): Attachment {
|
||||||
|
let heading: string;
|
||||||
|
let details: string[] = [];
|
||||||
|
|
||||||
|
if (args.status === "error") {
|
||||||
|
heading = `❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`;
|
||||||
|
} else if (args.status === "canceled") {
|
||||||
|
heading = "🚫 취소했습니다.";
|
||||||
|
} else if (args.action.type === "create_task") {
|
||||||
|
heading = "✅ 새 작업을 만들었어요";
|
||||||
|
details = actionDetailLines(args.action, args.ctx ?? {});
|
||||||
|
} else if (args.action.type === "update_task") {
|
||||||
|
heading = "✅ 작업을 업데이트했어요";
|
||||||
|
details = actionDetailLines(args.action, args.ctx ?? {});
|
||||||
|
} else {
|
||||||
|
heading = "❓ 한 번 더 확인이 필요해요";
|
||||||
|
details = actionDetailLines(args.action, args.ctx ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return CardFactory.adaptiveCard({
|
||||||
|
type: "AdaptiveCard",
|
||||||
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
version: "1.4",
|
||||||
|
body: [
|
||||||
|
{ type: "TextBlock", text: heading, weight: "Bolder", size: "Medium", wrap: true },
|
||||||
|
...details.map((t) => ({ type: "TextBlock", text: t, wrap: true, spacing: "Small" })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure-text clarification card (no buttons; user replies in chat). */
|
||||||
|
export function buildClarificationCard(question: string): Attachment {
|
||||||
|
return CardFactory.adaptiveCard({
|
||||||
|
type: "AdaptiveCard",
|
||||||
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
version: "1.4",
|
||||||
|
body: [
|
||||||
|
{ type: "TextBlock", text: "❓ 한 번 더 알려주세요", weight: "Bolder", size: "Medium", wrap: true },
|
||||||
|
{ type: "TextBlock", text: question, wrap: true, spacing: "Small" },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user