feat: add plan mode with read-only analysis, user approval, and structured execution
Introduces a plan/execute mode system for the AI agent. In plan mode the agent is read-only — it analyzes footage, asks clarifying questions, and submits a structured step-by-step plan. The user reviews and approves before the agent switches to execute mode to apply edits. New tools: submit_plan, ask_user, request_plan_approval, update_plan_step. UI: plan panel checklist, mode transition modal, question prompt with options, and Enter/Shift+Enter mode toggle in chat input.
This commit is contained in:
parent
d65435554f
commit
e588993706
|
|
@ -33,6 +33,14 @@ const WRITE_TOOLS = new Set([
|
|||
"update_keyframe_curve",
|
||||
]);
|
||||
|
||||
const PLAN_ONLY_TOOLS = new Set([
|
||||
"submit_plan",
|
||||
"ask_user",
|
||||
"request_plan_approval",
|
||||
]);
|
||||
|
||||
const EXECUTE_ONLY_TOOLS = new Set(["update_plan_step"]);
|
||||
|
||||
interface APIResponse {
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
|
|
@ -57,10 +65,16 @@ export async function run(
|
|||
while (iterations < MAX_ITERATIONS) {
|
||||
iterations++;
|
||||
|
||||
const liveMode = useAgentStore.getState().mode;
|
||||
const liveContext: AgentContext = { ...context, mode: liveMode };
|
||||
|
||||
const response = await fetch("/api/agent/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: workingMessages, context }),
|
||||
body: JSON.stringify({
|
||||
messages: workingMessages,
|
||||
context: liveContext,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -207,12 +221,43 @@ async function resolveToolCalls(
|
|||
): Promise<ToolResult[]> {
|
||||
const agentStore = useAgentStore.getState();
|
||||
const results: ToolResult[] = [];
|
||||
const currentMode = useAgentStore.getState().mode;
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
useAgentStore.getState().setActiveTool(tc.name);
|
||||
|
||||
const currentMode = useAgentStore.getState().permissionMode;
|
||||
if (currentMode === "ask" && WRITE_TOOLS.has(tc.name)) {
|
||||
if (currentMode === "plan" && WRITE_TOOLS.has(tc.name)) {
|
||||
results.push({
|
||||
toolCallId: tc.id,
|
||||
name: tc.name,
|
||||
result: null,
|
||||
error: `Cannot use '${tc.name}' in plan mode. This tool modifies the timeline. Use submit_plan to finalize your plan, then request_plan_approval to switch to execute mode.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentMode === "plan" && EXECUTE_ONLY_TOOLS.has(tc.name)) {
|
||||
results.push({
|
||||
toolCallId: tc.id,
|
||||
name: tc.name,
|
||||
result: null,
|
||||
error: `Cannot use '${tc.name}' in plan mode. This tool is only available during execution.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentMode === "execute" && PLAN_ONLY_TOOLS.has(tc.name)) {
|
||||
results.push({
|
||||
toolCallId: tc.id,
|
||||
name: tc.name,
|
||||
result: null,
|
||||
error: `Cannot use '${tc.name}' in execute mode. This tool is only available during planning.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPermissionMode = useAgentStore.getState().permissionMode;
|
||||
if (currentPermissionMode === "ask" && WRITE_TOOLS.has(tc.name)) {
|
||||
const approved = await requestApproval(tc);
|
||||
if (!approved) {
|
||||
results.push({
|
||||
|
|
|
|||
|
|
@ -1,20 +1,70 @@
|
|||
import type { AgentContext } from "@/agent/types";
|
||||
import type { AgentContext, AgentMode } from "@/agent/types";
|
||||
|
||||
export interface ToolSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the system prompt for the LLM from the editor context.
|
||||
*
|
||||
* Pure function — no client-only imports (no EditorCore, WASM, browser APIs).
|
||||
* Consumable server-side in the API route.
|
||||
*/
|
||||
const PLAN_MODE_INSTRUCTIONS = `
|
||||
## CURRENT MODE: PLAN (READ-ONLY)
|
||||
|
||||
You are in PLAN mode. You CANNOT make any edits to the timeline. You can only:
|
||||
- Read and analyze the project (list_project_assets, list_timeline, get_element, list_effects, get_effect, list_keyframes, list_animatable_properties)
|
||||
- Load media into your context (load_context)
|
||||
- Discover skills (list_skills, load_skill)
|
||||
- Ask the user questions (ask_user)
|
||||
- Submit your plan (submit_plan)
|
||||
|
||||
## YOUR WORKFLOW IN PLAN MODE:
|
||||
|
||||
1. **Analyze**: Use read-only tools to understand what media and timeline state exist. Use load_context to actually see/hear the footage.
|
||||
2. **Discover**: If the user's request matches a known editing pattern, call list_skills to find relevant techniques.
|
||||
3. **Clarify**: If anything is ambiguous, use ask_user to get clarification before planning.
|
||||
4. **Plan**: Once you understand the footage and the goal, call submit_plan with a structured step-by-step plan.
|
||||
|
||||
## PLAN QUALITY RULES:
|
||||
|
||||
- Each step must reference SPECIFIC tools and SPECIFIC values (timestamps, element IDs after discovery, effect params)
|
||||
- Steps should be ordered by dependency (split before delete, add text before animate, etc.)
|
||||
- If a skill was loaded, reference its techniques but ADAPT them to the actual footage — never copy blindly
|
||||
- Include timing estimates where possible
|
||||
- Be honest about limitations — if the footage doesn't support a technique, say so
|
||||
|
||||
## WHEN TO SUBMIT:
|
||||
|
||||
Submit the plan when:
|
||||
- You have enough context about the media content
|
||||
- All clarifying questions have been answered
|
||||
- You have a concrete, actionable plan
|
||||
|
||||
After submitting, the user will review and either approve (switching to execute mode) or request changes.
|
||||
`;
|
||||
|
||||
const EXECUTE_MODE_INSTRUCTIONS = `
|
||||
## CURRENT MODE: EXECUTE
|
||||
|
||||
You are in EXECUTE mode. You can read AND write to the timeline. Follow the approved plan step by step.
|
||||
|
||||
## EXECUTION RULES:
|
||||
|
||||
1. Follow the plan steps in order. Each step lists the tools to use.
|
||||
2. After completing a step, call update_plan_step to mark it done.
|
||||
3. If a step fails, note the error in update_plan_step and decide whether to continue or skip.
|
||||
4. If you discover the plan needs adjustment mid-execution, explain what changed and why.
|
||||
5. When all steps are done, provide a summary of what was accomplished.
|
||||
|
||||
## IF NO ACTIVE PLAN:
|
||||
|
||||
Execute the user's request directly. For complex edits (3+ tool calls), consider using submit_plan first to get user approval.
|
||||
`;
|
||||
|
||||
export function buildSystemPrompt(
|
||||
context: AgentContext,
|
||||
tools?: ToolSummary[],
|
||||
mode?: AgentMode,
|
||||
): string {
|
||||
const activeMode = mode ?? context.mode ?? "execute";
|
||||
|
||||
const mediaSection =
|
||||
context.mediaAssets.length > 0
|
||||
? `Active media assets:\n${context.mediaAssets.map((m) => `- [id: ${m.id}] ${m.name} (${m.type}, ${m.duration}s)`).join("\n")}`
|
||||
|
|
@ -45,9 +95,15 @@ export function buildSystemPrompt(
|
|||
"Only claim edits that were actually performed by tool calls in this conversation. Do not say you added, removed, cleaned, or updated text/subtitles unless the relevant add_text, update_text, delete_timeline_elements, or update_timeline_element_timing tool call succeeded.",
|
||||
"When the user asks to add titles, hooks, labels, captions, subtitles, or visible text, call add_text. Do not add text proactively for unrelated edit requests such as cutting silence unless the user asks for text.",
|
||||
"When you need to perform an action matching one of these tools, call the appropriate tool. For all other requests, respond directly in plain text.",
|
||||
"SKILLS: You have editing skills available — pre-built recipe workflows for common editing patterns. When the user asks for a complex edit (viral video, pitch, specific style, etc.), call list_skills to discover relevant skills, then call load_skill with the matching skillId to get full step-by-step instructions. Follow those instructions precisely using the standard editing tools. If the user's request doesn't match any skill, proceed with your own editing approach.",
|
||||
"SKILLS: You have editing skills available — pre-built technique libraries for common editing patterns. When the user asks for a complex edit (viral video, pitch, specific style, etc.), call list_skills to discover relevant skills, then call load_skill with the matching skillId to get technique definitions. Adapt those techniques to the actual footage content. If the user's request doesn't match any skill, proceed with your own editing approach.",
|
||||
);
|
||||
}
|
||||
|
||||
if (activeMode === "plan") {
|
||||
parts.push(PLAN_MODE_INSTRUCTIONS);
|
||||
} else {
|
||||
parts.push(EXECUTE_MODE_INSTRUCTIONS);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import type { AgentContext, ToolDefinition } from "@/agent/types";
|
||||
import { toolRegistry } from "@/agent/tools/registry";
|
||||
import { askUserSchema } from "@/agent/tools/schemas";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const askUserTool: ToolDefinition = {
|
||||
...askUserSchema,
|
||||
execute: async (
|
||||
args: Record<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<{ questionId: string; answer: string } | { error: string }> => {
|
||||
const { question, options } = args as {
|
||||
question: string;
|
||||
options?: Array<{ label: string; description?: string }>;
|
||||
};
|
||||
|
||||
if (!question || typeof question !== "string") {
|
||||
return { error: "question is required and must be a string" };
|
||||
}
|
||||
|
||||
const questionId = nanoid();
|
||||
const planStore = usePlanStore.getState();
|
||||
|
||||
planStore.setPendingQuestion({
|
||||
id: questionId,
|
||||
question,
|
||||
options: options && Array.isArray(options) ? options : undefined,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = usePlanStore.subscribe((state) => {
|
||||
if (state.pendingQuestion?.answer) {
|
||||
unsubscribe();
|
||||
const answer = state.pendingQuestion.answer;
|
||||
usePlanStore.getState().setPendingQuestion(null);
|
||||
resolve({ questionId, answer });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(askUserSchema.name, askUserTool);
|
||||
|
|
@ -38,3 +38,7 @@ import "@/agent/tools/update-keyframe-curve.tool";
|
|||
import "@/agent/tools/list-animatable-properties.tool";
|
||||
import "@/agent/skills/list-skills.tool";
|
||||
import "@/agent/skills/load-skill.tool";
|
||||
import "@/agent/tools/submit-plan.tool";
|
||||
import "@/agent/tools/ask-user.tool";
|
||||
import "@/agent/tools/request-plan-approval.tool";
|
||||
import "@/agent/tools/update-plan-step.tool";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { AgentContext, ToolDefinition } from "@/agent/types";
|
||||
import { toolRegistry } from "@/agent/tools/registry";
|
||||
import { requestPlanApprovalSchema } from "@/agent/tools/schemas";
|
||||
import { useAgentStore } from "@/stores/agent-store";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import type { AgentMode } from "@/agent/types";
|
||||
|
||||
const requestPlanApprovalTool: ToolDefinition = {
|
||||
...requestPlanApprovalSchema,
|
||||
execute: async (
|
||||
_args: Record<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<{ approved: boolean; mode: AgentMode } | { error: string }> => {
|
||||
const planStore = usePlanStore.getState();
|
||||
if (!planStore.plan) {
|
||||
return {
|
||||
error: "No plan has been submitted yet. Call submit_plan first.",
|
||||
};
|
||||
}
|
||||
|
||||
const agentStore = useAgentStore.getState();
|
||||
if (agentStore.mode !== "plan") {
|
||||
return { error: "Already in execute mode. No transition needed." };
|
||||
}
|
||||
|
||||
planStore.updatePlanStatus("approved");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
useAgentStore.getState().setPendingModeTransition({
|
||||
targetMode: "execute",
|
||||
resolve: (approved) => {
|
||||
useAgentStore.getState().setPendingModeTransition(null);
|
||||
if (approved) {
|
||||
useAgentStore.getState().setMode("execute");
|
||||
}
|
||||
resolve({
|
||||
approved,
|
||||
mode: approved ? "execute" : "plan",
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(requestPlanApprovalSchema.name, requestPlanApprovalTool);
|
||||
|
|
@ -305,6 +305,45 @@ export const loadSkillSchema: ToolSchema = {
|
|||
parameters: [{ key: "skillId", type: "string", required: true }],
|
||||
};
|
||||
|
||||
export const submitPlanSchema: ToolSchema = {
|
||||
name: "submit_plan",
|
||||
description:
|
||||
"Submits a structured editing plan with steps. Use ONLY in plan mode after analyzing the footage and understanding the user's request. Each step should describe a specific action with the tools to use. After submission, the user reviews and approves or requests changes before execution begins.",
|
||||
parameters: [
|
||||
{ key: "summary", type: "string", required: true },
|
||||
{ key: "steps", type: "array", required: true },
|
||||
{ key: "questions", type: "array", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export const askUserSchema: ToolSchema = {
|
||||
name: "ask_user",
|
||||
description:
|
||||
"Asks the user a question during plan mode. Use when you need clarification about their intent, preferences, or content details before finalizing the plan. Optionally provide quick-reply options for common answers. The user can also type a free-form response.",
|
||||
parameters: [
|
||||
{ key: "question", type: "string", required: true },
|
||||
{ key: "options", type: "array", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
export const requestPlanApprovalSchema: ToolSchema = {
|
||||
name: "request_plan_approval",
|
||||
description:
|
||||
"Requests user approval to switch from plan mode to execute mode. The user sees a modal with 'Continue planning' and 'Start editing' options. ALWAYS requires explicit user approval — there is no auto-approve. Use this when the plan is ready for execution.",
|
||||
parameters: [],
|
||||
};
|
||||
|
||||
export const updatePlanStepSchema: ToolSchema = {
|
||||
name: "update_plan_step",
|
||||
description:
|
||||
"Updates the status of a plan step during execution. Call this after completing each step to track progress. The plan panel updates in real-time. Use status 'done' for successful completion, 'skipped' if the step was unnecessary, or 'in_progress' if starting.",
|
||||
parameters: [
|
||||
{ key: "stepId", type: "string", required: true },
|
||||
{ key: "status", type: "string", required: true },
|
||||
{ key: "result", type: "string", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The exact list of schemas exposed to the LLM.
|
||||
* Excludes internal-only tools (transcribe_video, mock).
|
||||
|
|
@ -338,4 +377,8 @@ export const providerToolSchemas: ToolSchema[] = [
|
|||
listAnimatablePropertiesSchema,
|
||||
listSkillsSchema,
|
||||
loadSkillSchema,
|
||||
submitPlanSchema,
|
||||
askUserSchema,
|
||||
requestPlanApprovalSchema,
|
||||
updatePlanStepSchema,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import type { AgentContext, ToolDefinition } from "@/agent/types";
|
||||
import { toolRegistry } from "@/agent/tools/registry";
|
||||
import { submitPlanSchema } from "@/agent/tools/schemas";
|
||||
import { usePlanStore, createPlanFromSteps } from "@/stores/plan-store";
|
||||
|
||||
const submitPlanTool: ToolDefinition = {
|
||||
...submitPlanSchema,
|
||||
execute: async (
|
||||
args: Record<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<{ planId: string; stepCount: number } | { error: string }> => {
|
||||
const { summary, steps, questions } = args as {
|
||||
summary: string;
|
||||
steps: Array<{ description: string; tools: string[] }>;
|
||||
questions?: string[];
|
||||
};
|
||||
|
||||
if (!summary || typeof summary !== "string") {
|
||||
return { error: "summary is required and must be a string" };
|
||||
}
|
||||
|
||||
if (!Array.isArray(steps) || steps.length === 0) {
|
||||
return { error: "steps must be a non-empty array" };
|
||||
}
|
||||
|
||||
for (const [i, step] of steps.entries()) {
|
||||
if (!step.description || typeof step.description !== "string") {
|
||||
return { error: `Step ${i + 1} must have a description string` };
|
||||
}
|
||||
if (!Array.isArray(step.tools)) {
|
||||
return { error: `Step ${i + 1} must have a tools array` };
|
||||
}
|
||||
}
|
||||
|
||||
const plan = createPlanFromSteps(summary, steps, questions);
|
||||
usePlanStore.getState().setPlan(plan);
|
||||
|
||||
return { planId: plan.id, stepCount: plan.steps.length };
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(submitPlanSchema.name, submitPlanTool);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { AgentContext, ToolDefinition } from "@/agent/types";
|
||||
import { toolRegistry } from "@/agent/tools/registry";
|
||||
import { updatePlanStepSchema } from "@/agent/tools/schemas";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import type { PlanStepStatus } from "@/agent/types";
|
||||
|
||||
const VALID_STATUSES: PlanStepStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"done",
|
||||
"skipped",
|
||||
];
|
||||
|
||||
const updatePlanStepTool: ToolDefinition = {
|
||||
...updatePlanStepSchema,
|
||||
execute: async (
|
||||
args: Record<string, unknown>,
|
||||
_context: AgentContext,
|
||||
):
|
||||
| { success: boolean; stepId: string; status: string }
|
||||
| { error: string } => {
|
||||
const { stepId, status, result } = args as {
|
||||
stepId: string;
|
||||
status: string;
|
||||
result?: string;
|
||||
};
|
||||
|
||||
if (!stepId || typeof stepId !== "string") {
|
||||
return { error: "stepId is required" };
|
||||
}
|
||||
|
||||
if (!VALID_STATUSES.includes(status as PlanStepStatus)) {
|
||||
return {
|
||||
error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const planStore = usePlanStore.getState();
|
||||
const plan = planStore.plan;
|
||||
if (!plan) {
|
||||
return { error: "No active plan" };
|
||||
}
|
||||
|
||||
const step = plan.steps.find((s) => s.id === stepId);
|
||||
if (!step) {
|
||||
return {
|
||||
error: `Step not found: ${stepId}. Available: ${plan.steps.map((s) => s.id).join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
planStore.updateStepStatus(stepId, status as PlanStepStatus, result);
|
||||
|
||||
return { success: true, stepId, status };
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(updatePlanStepSchema.name, updatePlanStepTool);
|
||||
|
|
@ -5,12 +5,45 @@ export type ExecutionState =
|
|||
| "responding"
|
||||
| "error";
|
||||
|
||||
export type AgentMode = "plan" | "execute";
|
||||
|
||||
export type PlanStepStatus = "pending" | "in_progress" | "done" | "skipped";
|
||||
export type PlanStatus =
|
||||
| "drafting"
|
||||
| "awaiting_approval"
|
||||
| "approved"
|
||||
| "executing"
|
||||
| "completed";
|
||||
|
||||
export interface PlanStep {
|
||||
id: string;
|
||||
description: string;
|
||||
tools: string[];
|
||||
status: PlanStepStatus;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
summary: string;
|
||||
steps: PlanStep[];
|
||||
questions?: string[];
|
||||
status: PlanStatus;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface AgentQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options?: Array<{ label: string; description?: string }>;
|
||||
answer?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "system" | "user" | "assistant" | "tool_result";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
/** Associates a tool_result message with the ToolCall it answers. */
|
||||
toolCallId?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
|
@ -41,6 +74,7 @@ export interface AgentContext {
|
|||
}>;
|
||||
timelineTracks?: AgentTimelineTrack[];
|
||||
playbackTimeMs: number;
|
||||
mode?: AgentMode;
|
||||
}
|
||||
|
||||
export type AgentTimelineTrack = {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const chatRequestSchema = z.object({
|
|||
)
|
||||
.optional(),
|
||||
playbackTimeMs: z.number(),
|
||||
mode: z.enum(["plan", "execute"]).optional(),
|
||||
}) satisfies z.ZodType<AgentContext>,
|
||||
});
|
||||
|
||||
|
|
@ -89,7 +90,8 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const { messages, context } = result.data;
|
||||
|
||||
// Resolve provider config from LLM_* env vars
|
||||
const mode = context.mode;
|
||||
|
||||
const provider = process.env.LLM_PROVIDER;
|
||||
const apiKey = process.env.LLM_API_KEY;
|
||||
const model = process.env.LLM_MODEL;
|
||||
|
|
@ -107,8 +109,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const config: ProviderConfig = { provider, apiKey, model, baseUrl };
|
||||
|
||||
// Build system prompt with tool guidance
|
||||
const systemPrompt = buildSystemPrompt(context, providerToolSchemas);
|
||||
const systemPrompt = buildSystemPrompt(context, providerToolSchemas, mode);
|
||||
|
||||
// Delegate to provider adapter
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { QuestionExchangeIcon } from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
|
||||
export function AskUserPrompt() {
|
||||
const pendingQuestion = usePlanStore((s) => s.pendingQuestion);
|
||||
const answerQuestion = usePlanStore((s) => s.answerQuestion);
|
||||
const [freeForm, setFreeForm] = useState("");
|
||||
|
||||
const handleOptionClick = useCallback(
|
||||
(label: string) => {
|
||||
answerQuestion(label);
|
||||
setFreeForm("");
|
||||
},
|
||||
[answerQuestion],
|
||||
);
|
||||
|
||||
const handleFreeFormSubmit = useCallback(() => {
|
||||
const trimmed = freeForm.trim();
|
||||
if (!trimmed) return;
|
||||
answerQuestion(trimmed);
|
||||
setFreeForm("");
|
||||
}, [freeForm, answerQuestion]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleFreeFormSubmit();
|
||||
}
|
||||
},
|
||||
[handleFreeFormSubmit],
|
||||
);
|
||||
|
||||
if (!pendingQuestion || pendingQuestion.answer) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={QuestionExchangeIcon}
|
||||
className="size-4 shrink-0 text-blue-400"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<span className="text-xs font-medium text-blue-400">
|
||||
Agent question
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/90">{pendingQuestion.question}</p>
|
||||
|
||||
{pendingQuestion.options && pendingQuestion.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pendingQuestion.options.map((opt) => (
|
||||
<Button
|
||||
key={opt.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOptionClick(opt.label)}
|
||||
className="text-xs"
|
||||
title={opt.description}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={freeForm}
|
||||
onChange={(e) => setFreeForm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your answer..."
|
||||
className="border-border bg-input flex-1 rounded-md border px-2 py-1 text-xs outline-none focus:border-blue-500/50"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleFreeFormSubmit}
|
||||
disabled={!freeForm.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,11 +6,15 @@ import {
|
|||
Sent02Icon,
|
||||
FlashIcon,
|
||||
Shield01Icon,
|
||||
PlanChartIcon,
|
||||
Edit02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAgentStore, type PermissionMode } from "@/stores/agent-store";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { AgentMode } from "@/agent/types";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
|
|
@ -22,31 +26,51 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
|||
const permissionMode = useAgentStore((s) => s.permissionMode);
|
||||
const setPermissionMode = useAgentStore((s) => s.setPermissionMode);
|
||||
const pendingApproval = useAgentStore((s) => s.pendingApproval);
|
||||
const mode = useAgentStore((s) => s.mode);
|
||||
const setMode = useAgentStore((s) => s.setMode);
|
||||
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
|
||||
const plan = usePlanStore((s) => s.plan);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
}, [value, disabled, onSend]);
|
||||
const handleSend = useCallback(
|
||||
(forcedMode?: AgentMode) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
const targetMode = forcedMode ?? mode;
|
||||
if (targetMode !== mode) {
|
||||
setMode(targetMode);
|
||||
}
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
},
|
||||
[value, disabled, onSend, mode, setMode],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
handleSend("execute");
|
||||
}
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend("plan");
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const next: PermissionMode =
|
||||
permissionMode === "skip" ? "ask" : "skip";
|
||||
const next: AgentMode = mode === "execute" ? "plan" : "execute";
|
||||
setMode(next);
|
||||
}, [mode, setMode]);
|
||||
|
||||
const togglePermission = useCallback(() => {
|
||||
const next: PermissionMode = permissionMode === "skip" ? "ask" : "skip";
|
||||
setPermissionMode(next);
|
||||
}, [permissionMode, setPermissionMode]);
|
||||
|
||||
const isAsk = permissionMode === "ask";
|
||||
const isPlanMode = mode === "plan";
|
||||
|
||||
return (
|
||||
<div className="border-t p-3">
|
||||
|
|
@ -55,16 +79,38 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
|||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder="Ask the assistant..."
|
||||
disabled={disabled || !!pendingTransition}
|
||||
placeholder={
|
||||
isPlanMode
|
||||
? "Describe what you want (plan mode)..."
|
||||
: "Ask the assistant..."
|
||||
}
|
||||
rows={1}
|
||||
className="border-border bg-input focus-visible:border-primary/50 focus-visible:ring-primary/20 flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<Button
|
||||
variant={isAsk ? "default" : "secondary"}
|
||||
variant={isPlanMode ? "default" : "secondary"}
|
||||
size="icon"
|
||||
onClick={toggleMode}
|
||||
disabled={!!pendingApproval}
|
||||
disabled={!!pendingApproval || !!pendingTransition}
|
||||
aria-label={isPlanMode ? "Plan mode active" : "Switch to plan mode"}
|
||||
title={
|
||||
isPlanMode
|
||||
? "Plan mode (Shift+Enter to send in plan mode)"
|
||||
: "Edit mode (Enter to send)"
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isPlanMode ? PlanChartIcon : Edit02Icon}
|
||||
className={cn("size-4", isPlanMode && "text-blue-400")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant={isAsk ? "default" : "secondary"}
|
||||
size="icon"
|
||||
onClick={togglePermission}
|
||||
disabled={!!pendingApproval || !!pendingTransition}
|
||||
aria-label={isAsk ? "Ask permissions" : "Skip permissions"}
|
||||
title={isAsk ? "Ask permissions" : "Skip permissions"}
|
||||
>
|
||||
|
|
@ -77,8 +123,8 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
|||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
onClick={() => handleSend()}
|
||||
disabled={disabled || !value.trim() || !!pendingTransition}
|
||||
aria-label="Send message"
|
||||
>
|
||||
{disabled ? (
|
||||
|
|
@ -88,6 +134,21 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
|||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
Enter = edit mode <span className="text-muted-foreground/60">|</span>{" "}
|
||||
Shift+Enter = plan mode
|
||||
</span>
|
||||
{plan && (
|
||||
<span className="text-[10px] tabular-nums">
|
||||
<span
|
||||
className={cn(isPlanMode ? "text-blue-400" : "text-emerald-400")}
|
||||
>
|
||||
{isPlanMode ? "PLANNING" : "EXECUTING"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ import { Button } from "@/components/ui/button";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { useAgentStore } from "@/stores/agent-store";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ToolPermissionRequest } from "./tool-permission-request";
|
||||
import { PlanPanel } from "./plan-panel";
|
||||
import { ModeTransitionModal } from "./mode-transition-modal";
|
||||
import { AskUserPrompt } from "./ask-user-prompt";
|
||||
import { run as orchestratorRun } from "@/agent/orchestrator";
|
||||
import { EditorContextAdapter } from "@/agent/context";
|
||||
|
||||
|
|
@ -21,16 +25,16 @@ export function ChatPanel() {
|
|||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const setError = useChatStore((s) => s.setError);
|
||||
const pendingApproval = useAgentStore((s) => s.pendingApproval);
|
||||
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
|
||||
const plan = usePlanStore((s) => s.plan);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll on new messages or loading state change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-scroll when messages or loading change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, loading]);
|
||||
});
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
|
|
@ -52,6 +56,12 @@ export function ChatPanel() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{plan && (
|
||||
<div className="max-h-48 shrink-0 overflow-hidden border-b">
|
||||
<PlanPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !loading && !error ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
|
|
@ -80,6 +90,7 @@ export function ChatPanel() {
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
<AskUserPrompt />
|
||||
{pendingApproval && (
|
||||
<ToolPermissionRequest pending={pendingApproval} />
|
||||
)}
|
||||
|
|
@ -110,6 +121,8 @@ export function ChatPanel() {
|
|||
)}
|
||||
|
||||
<ChatInput onSend={handleSend} disabled={loading} />
|
||||
|
||||
{pendingTransition && <ModeTransitionModal />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { PlanChartIcon, Edit02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgentStore } from "@/stores/agent-store";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function ModeTransitionModal() {
|
||||
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
|
||||
const plan = usePlanStore((s) => s.plan);
|
||||
|
||||
const handleContinuePlanning = useCallback(() => {
|
||||
if (!pendingTransition) return;
|
||||
pendingTransition.resolve(false);
|
||||
}, [pendingTransition]);
|
||||
|
||||
const handleStartEditing = useCallback(() => {
|
||||
if (!pendingTransition) return;
|
||||
pendingTransition.resolve(true);
|
||||
}, [pendingTransition]);
|
||||
|
||||
if (!pendingTransition) return null;
|
||||
|
||||
const doneCount = plan?.steps.filter((s) => s.status === "done").length ?? 0;
|
||||
const totalCount = plan?.steps.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-background mx-4 max-w-sm rounded-lg border p-5 shadow-xl">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-sm font-semibold">Plan ready for execution</h3>
|
||||
{plan && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{plan.summary} — {totalCount} step{totalCount !== 1 ? "s" : ""}
|
||||
{doneCount > 0 && ` (${doneCount} already done)`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan && (
|
||||
<div className="bg-muted/50 max-h-40 overflow-auto rounded-md p-2">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{plan.steps.map((step) => (
|
||||
<li
|
||||
key={step.id}
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
step.status === "done"
|
||||
? "bg-emerald-400"
|
||||
: step.status === "skipped"
|
||||
? "bg-muted-foreground/30"
|
||||
: "bg-muted-foreground/60",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(step.status === "done" && "line-through")}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleStartEditing}
|
||||
className="w-full"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Edit02Icon}
|
||||
className="mr-2 size-4"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Start editing
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleContinuePlanning}
|
||||
className="w-full"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={PlanChartIcon}
|
||||
className="mr-2 size-4"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Continue planning
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
CheckmarkCircle02Icon,
|
||||
CancelCircleIcon,
|
||||
Loading03Icon,
|
||||
MinusSign01Icon,
|
||||
PlanChartIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { usePlanStore } from "@/stores/plan-store";
|
||||
import { useAgentStore } from "@/stores/agent-store";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { PlanStep, PlanStepStatus } from "@/agent/types";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
PlanStepStatus,
|
||||
{ icon: typeof CheckmarkCircle02Icon; className: string }
|
||||
> = {
|
||||
pending: {
|
||||
icon: MinusSign01Icon,
|
||||
className: "text-muted-foreground",
|
||||
},
|
||||
in_progress: {
|
||||
icon: Loading03Icon,
|
||||
className: "text-blue-400 animate-spin",
|
||||
},
|
||||
done: {
|
||||
icon: CheckmarkCircle02Icon,
|
||||
className: "text-emerald-400",
|
||||
},
|
||||
skipped: {
|
||||
icon: CancelCircleIcon,
|
||||
className: "text-muted-foreground/50",
|
||||
},
|
||||
};
|
||||
|
||||
function StepItem({ step }: { step: PlanStep }) {
|
||||
const config = STATUS_CONFIG[step.status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md px-2 py-1.5 text-xs",
|
||||
step.status === "skipped" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={config.icon}
|
||||
className={cn("mt-0.5 size-3.5 shrink-0", config.className)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
step.status === "done" && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.result && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{step.result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanPanel() {
|
||||
const plan = usePlanStore((s) => s.plan);
|
||||
const mode = useAgentStore((s) => s.mode);
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={PlanChartIcon}
|
||||
className="text-muted-foreground/50 size-8"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{mode === "plan"
|
||||
? "Analyzing footage to build a plan..."
|
||||
: "No active plan"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const doneCount = plan.steps.filter((s) => s.status === "done").length;
|
||||
const totalCount = plan.steps.length;
|
||||
const progress = totalCount > 0 ? (doneCount / totalCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">Plan</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{plan.summary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-emerald-400 h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px] tabular-nums">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{plan.steps.map((step) => (
|
||||
<StepItem key={step.id} step={step} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{plan.questions && plan.questions.length > 0 && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<span className="text-muted-foreground text-[10px] font-medium uppercase">
|
||||
Open questions
|
||||
</span>
|
||||
<ul className="mt-1 flex flex-col gap-0.5">
|
||||
{plan.questions.map((q) => (
|
||||
<li key={q} className="text-muted-foreground text-[11px]">
|
||||
{q}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { create } from "zustand";
|
||||
import type { AgentContext, ExecutionState, ToolCall } from "@/agent/types";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentMode,
|
||||
ExecutionState,
|
||||
ToolCall,
|
||||
} from "@/agent/types";
|
||||
|
||||
export type PermissionMode = "ask" | "skip";
|
||||
|
||||
|
|
@ -8,6 +13,11 @@ export interface PendingToolApproval {
|
|||
resolve: (approved: boolean) => void;
|
||||
}
|
||||
|
||||
export interface PendingModeTransition {
|
||||
targetMode: AgentMode;
|
||||
resolve: (approved: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT: AgentContext = {
|
||||
projectId: null,
|
||||
activeSceneId: null,
|
||||
|
|
@ -21,11 +31,15 @@ interface AgentState {
|
|||
context: AgentContext;
|
||||
permissionMode: PermissionMode;
|
||||
pendingApproval: PendingToolApproval | null;
|
||||
mode: AgentMode;
|
||||
pendingModeTransition: PendingModeTransition | null;
|
||||
setStatus: (status: ExecutionState) => void;
|
||||
setActiveTool: (tool: string | null) => void;
|
||||
setContext: (context: AgentContext) => void;
|
||||
setPermissionMode: (mode: PermissionMode) => void;
|
||||
setPendingApproval: (pending: PendingToolApproval | null) => void;
|
||||
setMode: (mode: AgentMode) => void;
|
||||
setPendingModeTransition: (transition: PendingModeTransition | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -35,16 +49,23 @@ export const useAgentStore = create<AgentState>()((set) => ({
|
|||
context: DEFAULT_CONTEXT,
|
||||
permissionMode: "skip",
|
||||
pendingApproval: null,
|
||||
mode: "execute",
|
||||
pendingModeTransition: null,
|
||||
setStatus: (status) => set({ status }),
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
setContext: (context) => set({ context }),
|
||||
setPermissionMode: (mode) => set({ permissionMode: mode }),
|
||||
setPendingApproval: (pending) => set({ pendingApproval: pending }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
setPendingModeTransition: (transition) =>
|
||||
set({ pendingModeTransition: transition }),
|
||||
reset: () =>
|
||||
set({
|
||||
status: "idle",
|
||||
activeTool: null,
|
||||
context: DEFAULT_CONTEXT,
|
||||
pendingApproval: null,
|
||||
mode: "execute",
|
||||
pendingModeTransition: null,
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { create } from "zustand";
|
||||
import { nanoid } from "nanoid";
|
||||
import type {
|
||||
AgentQuestion,
|
||||
Plan,
|
||||
PlanStepStatus,
|
||||
PlanStatus,
|
||||
} from "@/agent/types";
|
||||
|
||||
interface PlanState {
|
||||
plan: Plan | null;
|
||||
pendingQuestion: AgentQuestion | null;
|
||||
modeTransitionPending: boolean;
|
||||
|
||||
setPlan: (plan: Plan) => void;
|
||||
updatePlanStatus: (status: PlanStatus) => void;
|
||||
updateStepStatus: (
|
||||
stepId: string,
|
||||
status: PlanStepStatus,
|
||||
result?: string,
|
||||
) => void;
|
||||
clearPlan: () => void;
|
||||
|
||||
setPendingQuestion: (question: AgentQuestion | null) => void;
|
||||
answerQuestion: (answer: string) => void;
|
||||
|
||||
setModeTransitionPending: (pending: boolean) => void;
|
||||
}
|
||||
|
||||
export const usePlanStore = create<PlanState>()((set, get) => ({
|
||||
plan: null,
|
||||
pendingQuestion: null,
|
||||
modeTransitionPending: false,
|
||||
|
||||
setPlan: (plan) => set({ plan }),
|
||||
|
||||
updatePlanStatus: (status) => {
|
||||
const current = get().plan;
|
||||
if (!current) return;
|
||||
set({ plan: { ...current, status } });
|
||||
},
|
||||
|
||||
updateStepStatus: (stepId, status, result) => {
|
||||
const current = get().plan;
|
||||
if (!current) return;
|
||||
set({
|
||||
plan: {
|
||||
...current,
|
||||
steps: current.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? { ...s, status, ...(result !== undefined && { result }) }
|
||||
: s,
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearPlan: () => set({ plan: null, pendingQuestion: null }),
|
||||
|
||||
setPendingQuestion: (question) => set({ pendingQuestion: question }),
|
||||
|
||||
answerQuestion: (answer) => {
|
||||
const current = get().pendingQuestion;
|
||||
if (!current) return;
|
||||
set({ pendingQuestion: { ...current, answer } });
|
||||
},
|
||||
|
||||
setModeTransitionPending: (pending) =>
|
||||
set({ modeTransitionPending: pending }),
|
||||
}));
|
||||
|
||||
export function createPlanFromSteps(
|
||||
summary: string,
|
||||
steps: Array<{ description: string; tools: string[] }>,
|
||||
questions?: string[],
|
||||
): Plan {
|
||||
return {
|
||||
id: nanoid(),
|
||||
summary,
|
||||
steps: steps.map((s) => ({
|
||||
id: nanoid(),
|
||||
description: s.description,
|
||||
tools: s.tools,
|
||||
status: "pending" as PlanStepStatus,
|
||||
})),
|
||||
questions,
|
||||
status: "awaiting_approval" as PlanStatus,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue