diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5820f940..84641eb2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -63,7 +63,10 @@ export default defineConfig({ }, { text: 'Agent', - items: [{ text: 'Friday', link: '/agent/friday' }], + items: [ + { text: 'Friday', link: '/agent/friday' }, + { text: 'Planning', link: '/agent/planning' }, + ], }, ], }, @@ -117,6 +120,7 @@ export default defineConfig({ text: '智能体', items: [ { text: 'Friday', link: '/zh_CN/agent/friday' }, + { text: '计划管理', link: '/zh_CN/agent/planning' }, ], }, ], diff --git a/docs/tutorial/en/agent/Planning.md b/docs/tutorial/en/agent/Planning.md new file mode 100644 index 00000000..abe96a9b --- /dev/null +++ b/docs/tutorial/en/agent/Planning.md @@ -0,0 +1,19 @@ +# Planning + +When facing complex problems, Friday automatically creates a task plan and executes it step by step. + +- Supports inserting, deleting, and editing subtasks. +- Supports modifying the current status of subtasks. +- Supports reordering unexecuted subtasks via `drag and drop`. + +> **⚠️ Note: Completed subtasks do not support any modifications except deletion.** + +## Edit Subtasks + +For existing subtasks, use the insert, edit, and delete buttons shown below. +![plan edit buttons](assets/plan_edit.png) + +## Modify Subtask Status + +Click the subtask status icon to change its current status. +![plan status edit](assets/plan_state.png) diff --git a/docs/tutorial/en/agent/assets/plan_edit.png b/docs/tutorial/en/agent/assets/plan_edit.png new file mode 100644 index 00000000..8fd912d8 Binary files /dev/null and b/docs/tutorial/en/agent/assets/plan_edit.png differ diff --git a/docs/tutorial/en/agent/assets/plan_state.png b/docs/tutorial/en/agent/assets/plan_state.png new file mode 100644 index 00000000..798c3a53 Binary files /dev/null and b/docs/tutorial/en/agent/assets/plan_state.png differ diff --git a/docs/tutorial/zh_CN/agent/Planning.md b/docs/tutorial/zh_CN/agent/Planning.md new file mode 100644 index 00000000..2515b61e --- /dev/null +++ b/docs/tutorial/zh_CN/agent/Planning.md @@ -0,0 +1,19 @@ +# 计划管理 + +Friday在面对复杂问题时会自动创建任务计划,并按计划分步执行 + +- 支持用户对子任务的插入,删除,修改。 +- 支持用户修改子任务当前状态。 +- 支持用户通过`拖拽`修改未执行子任务的执行顺序。 + +> **⚠️ 注意:状态为已完成的子任务除了删除之外不支持任何修改操作** + +## 编辑子任务 + +对于已经存在的子任务,可以使用以下插入,修改,删除按钮进行编辑。 +![plan 编辑按钮](assets/plan_edit.png) + +## 修改子任务当前状态 + +点击子任务状态图标,可以修改当前状态 +![plan 状态编辑](assets/plan_state.png) diff --git a/docs/tutorial/zh_CN/agent/assets/plan_edit.png b/docs/tutorial/zh_CN/agent/assets/plan_edit.png new file mode 100644 index 00000000..07979f3c Binary files /dev/null and b/docs/tutorial/zh_CN/agent/assets/plan_edit.png differ diff --git a/docs/tutorial/zh_CN/agent/assets/plan_state.png b/docs/tutorial/zh_CN/agent/assets/plan_state.png new file mode 100644 index 00000000..3ed2e9b8 Binary files /dev/null and b/docs/tutorial/zh_CN/agent/assets/plan_state.png differ diff --git a/packages/app/friday/args.py b/packages/app/friday/args.py index b36affcc..480808ca 100644 --- a/packages/app/friday/args.py +++ b/packages/app/friday/args.py @@ -61,5 +61,5 @@ def get_args() -> Namespace: default={}, help="A JSON string representing a dictionary of keyword arguments to pass to the LLM generate method.", ) - args = parser.parse_args() + args, _ = parser.parse_known_args() return args diff --git a/packages/app/friday/clean_invalid_plans.py b/packages/app/friday/clean_invalid_plans.py new file mode 100644 index 00000000..621644e6 --- /dev/null +++ b/packages/app/friday/clean_invalid_plans.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Clean invalid plans from storage. + +This script removes plans with non-completed states (for example, 'in_progress') from +storage, as these should only exist in session, not in storage. +Storage should only contain completed ('done' or 'abandoned') plans. +""" +import asyncio +import sys +import os +import logging + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[logging.StreamHandler(sys.stdout)] +) +logger = logging.getLogger(__name__) + +# Add parent directory to path to enable direct execution +# Recommended: Run as module from project root: python -m packages.app.friday.clean_invalid_plans +sys.path.insert(0, os.path.dirname(__file__)) + +from plan_manager import JSONPlanStorage + + +async def clean_invalid_plans(): + """Remove invalid plans from storage.""" + plan_storage = JSONPlanStorage() + + # Get all plans + plans = await plan_storage.get_plans() + + logger.info(f"Found {len(plans)} plans in storage") + + # Find invalid plans (not done or abandoned) + invalid_plans = [p for p in plans if p.state not in ['done', 'abandoned']] + + if not invalid_plans: + logger.info("No invalid plans found. Storage is clean!") + return + + logger.info(f"\nFound {len(invalid_plans)} invalid plans:") + for plan in invalid_plans: + logger.info(f" - {plan.name} (state: {plan.state}, id: {plan.id})") + + # Remove invalid plans + for plan in invalid_plans: + try: + await plan_storage.delete_plan(plan.id) + logger.info(f"✓ Deleted: {plan.name}") + except Exception as e: + logger.error(f"✗ Failed to delete {plan.name}: {e}") + + logger.info(f"\nCleaning complete. Removed {len(invalid_plans)} invalid plans.") + + +if __name__ == "__main__": + asyncio.run(clean_invalid_plans()) + diff --git a/packages/app/friday/hook.py b/packages/app/friday/hook.py index 0883f963..1babeac2 100644 --- a/packages/app/friday/hook.py +++ b/packages/app/friday/hook.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- """The hooks for the agent""" +import asyncio from typing import Any import requests from agentscope.agent import AgentBase +from agentscope.plan import Plan, PlanNotebook def studio_pre_print_hook(self: AgentBase, kwargs: dict[str, Any]) -> None: @@ -50,3 +52,52 @@ def studio_post_reply_hook(self: AgentBase, *args, **kwargs) -> None: continue raise e from None + + +async def push_plan_hook( + notebook: PlanNotebook, + plan: Plan | None, +) -> None: + """Push plan updates to the studio frontend via HTTP request. + + This hook is triggered whenever the plan changes (create, update, delete). + It sends the current plan, and if plan is None (finished), also sends historical plans. + + Args: + notebook: The PlanNotebook instance + plan: The current plan (None if plan is finished/cleared) + """ + if not hasattr(push_plan_hook, 'url'): + return + + # Get current plan data + current_plan_data = plan.model_dump() if plan else None + + # If plan is None (finished), also load historical plans in the same request + historical_plans_data = [] + if plan is None: + try: + historical_plans = await notebook.storage.get_plans() + historical_plans_data = [p.model_dump() for p in historical_plans] + except Exception as e: + print(f"Error fetching historical plans: {e}") + + n_retry = 0 + while True: + try: + res = await asyncio.to_thread( + requests.post, + f"{push_plan_hook.url}/trpc/pushCurrentPlanToFridayApp", + json={ + "currentPlan": current_plan_data, + "historicalPlans": historical_plans_data, + }, + ) + res.raise_for_status() + break + except Exception as e: + if n_retry < 3: + n_retry += 1 + continue + print(f"Failed to push plan after {n_retry} retries: {e}") + break diff --git a/packages/app/friday/main.py b/packages/app/friday/main.py index 817006a6..f557171c 100644 --- a/packages/app/friday/main.py +++ b/packages/app/friday/main.py @@ -19,10 +19,12 @@ insert_text_file, view_text_file, ) +from agentscope.plan import PlanNotebook, Plan, SubTask from hook import ( studio_pre_print_hook, studio_post_reply_hook, + push_plan_hook, ) from args import get_args from model import get_model, get_formatter @@ -31,14 +33,18 @@ view_agentscope_readme, view_agentscope_faq, ) -from utils.common import get_local_file_path +from utils.common import get_local_file_path, save_studio_url from utils.connect import StudioConnect from utils.constants import FRIDAY_SESSION_ID +from plan_manager import JSONPlanStorage async def main(): args = get_args() + # Save studio URL to config file for API access + save_studio_url(args.studio_url) + studio_pre_print_hook.url = args.studio_url # Forward message to the studio @@ -92,6 +98,18 @@ async def main(): model = get_model(args.llmProvider, args.modelName, args.apiKey, args.clientKwargs, args.generateKwargs) formatter = get_formatter(args.llmProvider) + # Create PlanNotebook with JSON storage + plan_storage = JSONPlanStorage() + plan_notebook = PlanNotebook(storage=plan_storage) + + # Register plan change hook to push plan updates to frontend + # When plan is finished (plan=None), it also pushes historical plans in the same request + push_plan_hook.url = args.studio_url + plan_notebook.register_plan_change_hook( + "push_plan", + push_plan_hook, + ) + # Create the ReAct agent agent = ReActAgent( name="Friday", @@ -135,6 +153,7 @@ async def main(): memory=InMemoryMemory(), max_iters=50, enable_meta_tool=True, + plan_notebook=plan_notebook, ) path_dialog_history = get_local_file_path("") @@ -150,7 +169,8 @@ async def main(): await session.load_session_state( session_id=FRIDAY_SESSION_ID, - friday=agent + friday=agent, + plan_notebook=plan_notebook ) # The socket is used for realtime steering @@ -159,10 +179,11 @@ async def main(): await agent(Msg("user", json5.loads(args.query), "user")) await socket.disconnect() - # Save dialog history + # Save dialog history and plan notebook state await session.save_session_state( session_id=FRIDAY_SESSION_ID, - friday=agent + friday=agent, + plan_notebook=plan_notebook ) if __name__ == '__main__': diff --git a/packages/app/friday/plan_manager/__init__.py b/packages/app/friday/plan_manager/__init__.py new file mode 100644 index 00000000..670cfb40 --- /dev/null +++ b/packages/app/friday/plan_manager/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""Plan manager module for Friday agent.""" +from .storage import JSONPlanStorage +from .api import get_plans_from_storage + +__all__ = ['JSONPlanStorage', 'get_plans_from_storage'] diff --git a/packages/app/friday/plan_manager/api.py b/packages/app/friday/plan_manager/api.py new file mode 100644 index 00000000..e2604531 --- /dev/null +++ b/packages/app/friday/plan_manager/api.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +"""Plan management API for frontend to manipulate plans.""" +import sys +import json +from pathlib import Path +from agentscope.plan import PlanNotebook, Plan, SubTask +from plan_manager import JSONPlanStorage +from utils.common import get_local_file_path, get_studio_url +from utils.constants import FRIDAY_SESSION_ID +from hook import push_plan_hook + + +def _extract_message_from_tool_response(result) -> str: + """Extract message text from ToolResponse object. + + Args: + result: ToolResponse object with content list + + Returns: + str: Extracted message text or "Success" as default + """ + if not result.content: + return "Success" + + first_content = result.content[0] + if isinstance(first_content, dict): + return first_content.get("text", "Success") + elif hasattr(first_content, "text"): + return first_content.text + return "Success" + + +def get_plans_from_storage() -> dict: + """Get current and historical plans using PlanNotebook and JSONSession. + + This function initializes a PlanNotebook and its JSONPlanStorage, + restores state from JSONSession, and then returns both the current + plan and all historical plans. + + Returns: + A dict containing: + - currentPlan: The current plan (None if no plan is active) + - historicalPlans: List of all historical plans (done/abandoned) + """ + import asyncio + from agentscope.session import JSONSession + + # Resolve data directory using AgentScope helper + path_dialog_history = get_local_file_path("") + + # Initialize storage and notebook + plan_storage = JSONPlanStorage() + plan_notebook = PlanNotebook(storage=plan_storage) + + # Restore notebook state from session if exists + try: + session = JSONSession(save_dir=path_dialog_history) + except TypeError: + session = JSONSession( + session_id=FRIDAY_SESSION_ID, + save_dir=path_dialog_history, + ) + + try: + asyncio.run( + session.load_session_state( + session_id=FRIDAY_SESSION_ID, + plan_notebook=plan_notebook, + ) + ) + except Exception as e: + # If no session yet, just continue with empty notebook + print(f"No existing session found, starting fresh: {e}") + + # Load all plans from storage (these are historical plans only) + try: + plans = asyncio.run(plan_storage.get_plans()) + except Exception: + plans = [] + + # Get current plan from notebook (from session state) + # Note: current_plan should only come from session, not from storage + current_plan = plan_notebook.current_plan + + # Historical plans: all plans in storage except the current one (if any) + if current_plan is None: + historical_plans = plans + else: + historical_plans = [ + p for p in plans if p.id != current_plan.id + ] + + return { + "currentPlan": current_plan.model_dump() if current_plan else None, + "historicalPlans": [p.model_dump() for p in historical_plans], + } + + +def _init_plan_notebook() -> tuple[PlanNotebook, JSONPlanStorage]: + """Initialize PlanNotebook with storage, session, and hooks. + + Returns: + tuple of (plan_notebook, plan_storage) + """ + import asyncio + from agentscope.session import JSONSession + + path_dialog_history = get_local_file_path("") + plan_storage = JSONPlanStorage() + plan_notebook = PlanNotebook(storage=plan_storage) + + # Register plan change hook to push updates to frontend + studio_url = get_studio_url() + if studio_url: + push_plan_hook.url = studio_url + plan_notebook.register_plan_change_hook("push_plan", push_plan_hook) + + # Load the notebook state from session + try: + session = JSONSession(save_dir=path_dialog_history) + except TypeError: + session = JSONSession( + session_id=FRIDAY_SESSION_ID, + save_dir=path_dialog_history, + ) + + try: + asyncio.run( + session.load_session_state( + session_id=FRIDAY_SESSION_ID, + plan_notebook=plan_notebook, + ) + ) + except Exception as e: + print(f"No existing session found, starting fresh: {e}") + + return plan_notebook, plan_storage + + +def _save_plan_state( + plan_notebook: PlanNotebook, + plan_storage: JSONPlanStorage, +) -> None: + """Save plan state to storage and session.""" + import asyncio + from agentscope.session import JSONSession + + path_dialog_history = get_local_file_path("") + + try: + session = JSONSession(save_dir=path_dialog_history) + except TypeError: + session = JSONSession( + session_id=FRIDAY_SESSION_ID, + save_dir=path_dialog_history, + ) + + if plan_notebook.current_plan: + # Only save completed or abandoned plans to storage + # In-progress plans should only exist in session + if plan_notebook.current_plan.state in ['done', 'abandoned']: + asyncio.run(plan_storage.add_plan(plan_notebook.current_plan, override=True)) + + asyncio.run( + session.save_session_state( + session_id=FRIDAY_SESSION_ID, + plan_notebook=plan_notebook, + ) + ) + + +def revise_plan( + action: str, + subtask_idx: int, + subtask_data: dict = None, +) -> dict: + """Revise the current plan. + + Args: + action: The action to perform ('add', 'revise', 'delete') + subtask_idx: The index of the subtask + subtask_data: The subtask data (for add/revise actions) + + Returns: + dict with 'success' and 'message' keys + """ + try: + import asyncio + + plan_notebook, plan_storage = _init_plan_notebook() + + if plan_notebook.current_plan is None: + return {"success": False, "message": "No current plan found"} + + # Create SubTask object if needed + subtask = None + if subtask_data and action in ['add', 'revise']: + subtask = SubTask(**subtask_data) + + # Call the revise method (hook will be triggered automatically) + result = asyncio.run( + plan_notebook.revise_current_plan( + subtask_idx=subtask_idx, + action=action, + subtask=subtask, + ) + ) + + # Save the updated plan back to storage and session + _save_plan_state(plan_notebook, plan_storage) + + return { + "success": True, + "message": _extract_message_from_tool_response(result), + "plan": plan_notebook.current_plan.model_dump() if plan_notebook.current_plan else None + } + + except Exception as e: + import traceback + print(traceback.format_exc()) + return { + "success": False, + "message": str(e), + } + + +def update_subtask_state( + subtask_idx: int, + state: str, +) -> dict: + """Update the state of a subtask. + + Args: + subtask_idx: The index of the subtask + state: The new state + + Returns: + dict with 'success' and 'message' keys + """ + try: + import asyncio + + plan_notebook, plan_storage = _init_plan_notebook() + + if plan_notebook.current_plan is None: + return {"success": False, "message": "No current plan found"} + + # Call the update method (hook will be triggered automatically) + result = asyncio.run( + plan_notebook.update_subtask_state( + subtask_idx=subtask_idx, + state=state, + ) + ) + + # Save the updated plan back to storage and session + _save_plan_state(plan_notebook, plan_storage) + + return { + "success": True, + "message": _extract_message_from_tool_response(result), + "plan": plan_notebook.current_plan.model_dump() if plan_notebook.current_plan else None + } + + except Exception as e: + import traceback + print(traceback.format_exc()) + return { + "success": False, + "message": str(e), + } + + +def finish_subtask( + subtask_idx: int, + outcome: str, +) -> dict: + """Finish a subtask with the given outcome. + + Args: + subtask_idx: The index of the subtask + outcome: The specific outcome of the subtask + + Returns: + dict with 'success' and 'message' keys + """ + try: + import asyncio + + plan_notebook, plan_storage = _init_plan_notebook() + + if plan_notebook.current_plan is None: + return {"success": False, "message": "No current plan found"} + + # Call the finish method (hook will be triggered automatically) + result = asyncio.run( + plan_notebook.finish_subtask( + subtask_idx=subtask_idx, + subtask_outcome=outcome, + ) + ) + + # Save the updated plan back to storage and session + _save_plan_state(plan_notebook, plan_storage) + + return { + "success": True, + "message": _extract_message_from_tool_response(result), + "plan": plan_notebook.current_plan.model_dump() if plan_notebook.current_plan else None + } + + except Exception as e: + import traceback + print(traceback.format_exc()) + return { + "success": False, + "message": str(e), + } + + +def reorder_subtasks( + from_index: int, + to_index: int, +) -> dict: + """Reorder subtasks by atomically moving one from from_index to to_index. + + Args: + from_index: The current index of the subtask to move + to_index: The target index to insert the subtask at + + Returns: + dict with 'success', 'message', and 'plan' keys + """ + try: + import asyncio + + plan_notebook, plan_storage = _init_plan_notebook() + + if plan_notebook.current_plan is None: + return {"success": False, "message": "No current plan found"} + + subtasks = plan_notebook.current_plan.subtasks + n = len(subtasks) + if from_index < 0 or from_index >= n: + return {"success": False, "message": f"Invalid from_index: {from_index}"} + if to_index < 0 or to_index >= n: + return {"success": False, "message": f"Invalid to_index: {to_index}"} + if from_index == to_index: + return { + "success": True, + "message": "No change needed", + "plan": plan_notebook.current_plan.model_dump(), + } + + # Atomically move: remove then insert at target position + subtask_to_move = subtasks.pop(from_index) + subtasks.insert(to_index, subtask_to_move) + + # Trigger push hook manually (direct list mutation bypasses notebook hooks) + asyncio.run(push_plan_hook(plan_notebook, plan_notebook.current_plan)) + + # Save the updated plan back to storage and session + _save_plan_state(plan_notebook, plan_storage) + + return { + "success": True, + "message": "Subtasks reordered successfully", + "plan": plan_notebook.current_plan.model_dump(), + } + + except Exception as e: + import traceback + print(traceback.format_exc()) + return { + "success": False, + "message": str(e), + } + + +if __name__ == "__main__": + # Read arguments from command line + # Format: python api.py + if len(sys.argv) < 2: + print(json.dumps({"success": False, "message": "Not enough arguments"})) + sys.exit(1) + + function_name = sys.argv[1] + + try: + if function_name == "revise_plan": + if len(sys.argv) < 4: + print(json.dumps({"success": False, "message": "Not enough arguments for revise_plan"})) + sys.exit(1) + + action = sys.argv[2] + subtask_idx = int(sys.argv[3]) + subtask_data = json.loads(sys.argv[4]) if len(sys.argv) > 4 else None + + result = revise_plan(action, subtask_idx, subtask_data) + + elif function_name == "update_subtask_state": + if len(sys.argv) < 4: + print(json.dumps({"success": False, "message": "Not enough arguments for update_subtask_state"})) + sys.exit(1) + + subtask_idx = int(sys.argv[2]) + state = sys.argv[3] + + result = update_subtask_state(subtask_idx, state) + + elif function_name == "finish_subtask": + if len(sys.argv) < 4: + print(json.dumps({"success": False, "message": "Not enough arguments for finish_subtask"})) + sys.exit(1) + + subtask_idx = int(sys.argv[2]) + outcome = sys.argv[3] + + result = finish_subtask(subtask_idx, outcome) + + else: + print(json.dumps({"success": False, "message": f"Unknown function: {function_name}"})) + sys.exit(1) + + print(json.dumps(result)) + + except Exception as e: + import traceback + print(json.dumps({ + "success": False, + "message": str(e), + "traceback": traceback.format_exc() + })) + sys.exit(1) diff --git a/packages/app/friday/plan_manager/storage.py b/packages/app/friday/plan_manager/storage.py new file mode 100644 index 00000000..bcbd5c40 --- /dev/null +++ b/packages/app/friday/plan_manager/storage.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""JSON-based plan storage implementation using JSONSession.""" +from collections import OrderedDict + +from agentscope.plan import Plan, PlanStorageBase + + +class JSONPlanStorage(PlanStorageBase): + """Plan storage that persists via PlanNotebook's JSONSession. + + This class does not manage its own session or serialization. + All persistence is handled by PlanNotebook, which serializes + this storage object as part of its own state. + """ + + def __init__(self) -> None: + """Initialize the JSON plan storage. + + The storage only maintains an in-memory OrderedDict of plans. + Persistence is delegated to the parent PlanNotebook. + """ + super().__init__() + self.plans = OrderedDict() + + # Register state serialization for the plans dict + # This will be triggered when PlanNotebook serializes + self.register_state( + "plans", + lambda plans: {k: v.model_dump() for k, v in plans.items()}, + lambda json_data: OrderedDict( + (k, Plan.model_validate(v)) for k, v in json_data.items() + ), + ) + + async def add_plan(self, plan: Plan, override: bool = True) -> None: + """Add a plan to the storage. + + Persistence is handled by PlanNotebook's save mechanism. + + Args: + plan: The plan to be added + override: Whether to override the existing plan with the same ID + """ + if plan.id in self.plans and not override: + raise ValueError( + f"Plan with id {plan.id} already exists.", + ) + self.plans[plan.id] = plan + + async def delete_plan(self, plan_id: str) -> None: + """Delete a plan from the storage. + + Persistence is handled by PlanNotebook's save mechanism. + + Args: + plan_id: The ID of the plan to be deleted + """ + self.plans.pop(plan_id, None) + + async def get_plans(self) -> list[Plan]: + """Get all plans from the storage. + + Returns: + A list of all plans in the storage + """ + return list(self.plans.values()) + + async def get_plan(self, plan_id: str) -> Plan | None: + """Get a plan by its ID. + + Args: + plan_id: The ID of the plan to be retrieved + + Returns: + The plan with the specified ID, or None if not found + """ + return self.plans.get(plan_id, None) diff --git a/packages/app/friday/utils/common.py b/packages/app/friday/utils/common.py index f539836b..95a8e5e3 100644 --- a/packages/app/friday/utils/common.py +++ b/packages/app/friday/utils/common.py @@ -2,6 +2,7 @@ """Utility functions for file path management in AgentScope Studio.""" import platform import os +import json from utils.constants import NAME_STUDIO, NAME_APP @@ -26,3 +27,35 @@ def get_local_file_path(filename: str) -> str: os.makedirs(os.path.join(local_path, NAME_APP), exist_ok=True) return os.path.join(local_path, NAME_APP, filename) + + +def save_studio_url(url: str) -> None: + """Save the studio URL to a config file. + + Args: + url: The studio URL to save + """ + config_path = get_local_file_path(".studio_config.json") + config = {"studio_url": url} + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f) + + +def get_studio_url() -> str | None: + """Get the studio URL from the config file. + + Returns: + The studio URL if exists, otherwise None + """ + config_path = get_local_file_path(".studio_config.json") + + if not os.path.exists(config_path): + return None + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + return config.get("studio_url") + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Warning: Failed to read studio config {config_path}: {e}") + return None diff --git a/packages/client/src/components/plan/EditSubtaskDialog.tsx b/packages/client/src/components/plan/EditSubtaskDialog.tsx new file mode 100644 index 00000000..6391873c --- /dev/null +++ b/packages/client/src/components/plan/EditSubtaskDialog.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SubTask, SubTaskStatus } from '@shared/types'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; + +interface EditSubtaskDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + subtask: SubTask | null; + onSave: (subtask: SubTask) => void; + mode?: 'edit' | 'add'; + customTitle?: string; +} + +export function EditSubtaskDialog({ + open, + onOpenChange, + subtask, + onSave, + mode = 'edit', + customTitle, +}: EditSubtaskDialogProps) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [expectedOutcome, setExpectedOutcome] = useState(''); + + useEffect(() => { + if (subtask) { + setName(subtask.name); + setDescription(subtask.description); + setExpectedOutcome(subtask.expected_outcome || ''); + } else { + // Reset for add mode + setName(''); + setDescription(''); + setExpectedOutcome(''); + } + }, [subtask, open]); + + const handleSave = () => { + if (!name.trim() || !description.trim()) { + return; + } + + const updatedSubtask: SubTask = { + ...(subtask || {}), + name: name.trim(), + description: description.trim(), + expected_outcome: expectedOutcome.trim(), + created_at: subtask?.created_at || new Date().toISOString(), + state: subtask?.state || SubTaskStatus.TODO, + outcome: subtask?.outcome || null, + finished_at: subtask?.finished_at || null, + }; + + onSave(updatedSubtask); + onOpenChange(false); + }; + + return ( + + + + + {customTitle || + (mode === 'edit' + ? t('plan.edit_subtask') + : t('plan.add_subtask'))} + + +
+
+ + setName(e.target.value)} + placeholder={t('plan.subtask_name_placeholder')} + className="w-full" + /> +
+
+ +