mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 20:34:45 +08:00
feat(agent):Supports custom agent and chat experience with refactoring (#957)
* feat: add agent management functionality with creation, editing, and deletion * feat: enhance agent creation and chat experience - Added AgentWelcome component to display agent description on new thread creation. - Improved agent name validation with availability check during agent creation. - Updated NewAgentPage to handle agent creation flow more effectively, including enhanced error handling and user feedback. - Refactored chat components to streamline message handling and improve user experience. - Introduced new bootstrap skill for personalized onboarding conversations, including detailed conversation phases and a structured SOUL.md template. - Updated localization files to reflect new features and error messages. - General code cleanup and optimizations across various components and hooks. * Refactor workspace layout and agent management components - Updated WorkspaceLayout to use useLayoutEffect for sidebar state initialization. - Removed unused AgentFormDialog and related edit functionality from AgentCard. - Introduced ArtifactTrigger component to manage artifact visibility. - Enhanced ChatBox to handle artifact selection and display. - Improved message list rendering logic to avoid loading states. - Updated localization files to remove deprecated keys and add new translations. - Refined hooks for local settings and thread management to improve performance and clarity. - Added temporal awareness guidelines to deep research skill documentation. * feat: refactor chat components and introduce thread management hooks * feat: improve artifact file detail preview logic and clean up console logs * feat: refactor lead agent creation logic and improve logging details * feat: validate agent name format and enhance error handling in agent setup * feat: simplify thread search query by removing unnecessary metadata * feat: update query key in useDeleteThread and useRenameThread for consistency * feat: add isMock parameter to thread and artifact handling for improved testing * fix: reorder import of setup_agent for consistency in builtins module * feat: append mock parameter to thread links in CaseStudySection for testing purposes * fix: update load_agent_soul calls to use cfg.name for improved clarity * fix: update date format in apply_prompt_template for consistency * feat: integrate isMock parameter into artifact content loading for enhanced testing * docs: add license section to SKILL.md for clarity and attribution * feat(agent): enhance model resolution and agent configuration handling * chore: remove unused import of _resolve_model_name from agents * feat(agent): remove unused field * fix(agent): set default value for requested_model_name in _resolve_model_name function * feat(agent): update get_available_tools call to handle optional agent_config and improve middleware function signature --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
1
Makefile
1
Makefile
@@ -166,6 +166,7 @@ dev:
|
||||
@echo " → Nginx: Reverse Proxy"
|
||||
@echo ""
|
||||
@cleanup() { \
|
||||
trap - INT TERM; \
|
||||
echo ""; \
|
||||
echo "Shutting down services..."; \
|
||||
pkill -f "langgraph dev" 2>/dev/null || true; \
|
||||
|
||||
@@ -14,6 +14,7 @@ from src.agents.middlewares.title_middleware import TitleMiddleware
|
||||
from src.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||
from src.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||
from src.agents.thread_state import ThreadState
|
||||
from src.config.agents_config import load_agent_config
|
||||
from src.config.app_config import get_app_config
|
||||
from src.config.summarization_config import get_summarization_config
|
||||
from src.models import create_chat_model
|
||||
@@ -22,14 +23,12 @@ from src.sandbox.middleware import SandboxMiddleware
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_model_name(requested_model_name: str | None) -> str:
|
||||
def _resolve_model_name(requested_model_name: str | None = None) -> str:
|
||||
"""Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured."""
|
||||
app_config = get_app_config()
|
||||
default_model_name = app_config.models[0].name if app_config.models else None
|
||||
if default_model_name is None:
|
||||
raise ValueError(
|
||||
"No chat models are configured. Please configure at least one model in config.yaml."
|
||||
)
|
||||
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
|
||||
|
||||
if requested_model_name and app_config.get_model_config(requested_model_name):
|
||||
return requested_model_name
|
||||
@@ -205,11 +204,12 @@ Being proactive with task management demonstrates thoroughness and ensures all r
|
||||
# MemoryMiddleware queues conversation for memory update (after TitleMiddleware)
|
||||
# ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM
|
||||
# ClarificationMiddleware should be last to intercept clarification requests after model calls
|
||||
def _build_middlewares(config: RunnableConfig, model_name: str | None):
|
||||
def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_name: str | None = None):
|
||||
"""Build middleware chain based on runtime configuration.
|
||||
|
||||
Args:
|
||||
config: Runtime configuration containing configurable options like is_plan_mode.
|
||||
agent_name: If provided, MemoryMiddleware will use per-agent memory storage.
|
||||
|
||||
Returns:
|
||||
List of middleware instances.
|
||||
@@ -231,7 +231,7 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None):
|
||||
middlewares.append(TitleMiddleware())
|
||||
|
||||
# Add MemoryMiddleware (after TitleMiddleware)
|
||||
middlewares.append(MemoryMiddleware())
|
||||
middlewares.append(MemoryMiddleware(agent_name=agent_name))
|
||||
|
||||
# Add ViewImageMiddleware only if the current model supports vision.
|
||||
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
|
||||
@@ -254,28 +254,36 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None):
|
||||
def make_lead_agent(config: RunnableConfig):
|
||||
# Lazy import to avoid circular dependency
|
||||
from src.tools import get_available_tools
|
||||
from src.tools.builtins import setup_agent
|
||||
|
||||
thinking_enabled = config.get("configurable", {}).get("thinking_enabled", True)
|
||||
reasoning_effort = config.get("configurable", {}).get("reasoning_effort", None)
|
||||
requested_model_name = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model")
|
||||
model_name = _resolve_model_name(requested_model_name)
|
||||
if model_name is None:
|
||||
raise ValueError(
|
||||
"No chat model could be resolved. Please configure at least one model in "
|
||||
"config.yaml or provide a valid 'model_name'/'model' in the request."
|
||||
)
|
||||
requested_model_name: str | None = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model")
|
||||
is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False)
|
||||
subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False)
|
||||
max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = config.get("configurable", {}).get("is_bootstrap", False)
|
||||
agent_name = config.get("configurable", {}).get("agent_name")
|
||||
|
||||
agent_config = load_agent_config(agent_name)
|
||||
# Custom agent model or fallback to global/default model resolution
|
||||
agent_model_name = agent_config.model if agent_config and agent_config.model else _resolve_model_name()
|
||||
|
||||
# Final model name resolution with request override, then agent config, then global default
|
||||
model_name = requested_model_name or agent_model_name
|
||||
|
||||
app_config = get_app_config()
|
||||
model_config = app_config.get_model_config(model_name) if model_name else None
|
||||
if thinking_enabled and model_config is not None and not model_config.supports_thinking:
|
||||
|
||||
if model_config is None:
|
||||
raise ValueError("No chat model could be resolved. Please configure at least one model in config.yaml or provide a valid 'model_name'/'model' in the request.")
|
||||
if thinking_enabled and not model_config.supports_thinking:
|
||||
logger.warning(f"Thinking mode is enabled but model '{model_name}' does not support it; fallback to non-thinking mode.")
|
||||
thinking_enabled = False
|
||||
|
||||
logger.info(
|
||||
"thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s",
|
||||
"Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s",
|
||||
agent_name or "default",
|
||||
thinking_enabled,
|
||||
reasoning_effort,
|
||||
model_name,
|
||||
@@ -287,8 +295,10 @@ def make_lead_agent(config: RunnableConfig):
|
||||
# Inject run metadata for LangSmith trace tagging
|
||||
if "metadata" not in config:
|
||||
config["metadata"] = {}
|
||||
|
||||
config["metadata"].update(
|
||||
{
|
||||
"agent_name": agent_name or "default",
|
||||
"model_name": model_name or "default",
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
@@ -297,10 +307,23 @@ def make_lead_agent(config: RunnableConfig):
|
||||
}
|
||||
)
|
||||
|
||||
if is_bootstrap:
|
||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||
system_prompt = apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, available_skills=set(["bootstrap"]))
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort),
|
||||
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled),
|
||||
tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled) + [setup_agent],
|
||||
middleware=_build_middlewares(config, model_name=model_name),
|
||||
system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents),
|
||||
system_prompt=system_prompt,
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
# Default lead agent (unchanged behavior)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort),
|
||||
tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled),
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name),
|
||||
system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from src.config.agents_config import load_agent_soul
|
||||
from src.skills import load_skills
|
||||
|
||||
|
||||
@@ -148,9 +149,10 @@ bash("npm test") # Direct execution, not task()
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """
|
||||
<role>
|
||||
You are DeerFlow 2.0, an open-source super agent.
|
||||
You are {agent_name}, an open-source super agent.
|
||||
</role>
|
||||
|
||||
{soul}
|
||||
{memory_context}
|
||||
|
||||
<thinking_style>
|
||||
@@ -280,9 +282,12 @@ Recent breakthroughs in language models have also accelerated progress
|
||||
"""
|
||||
|
||||
|
||||
def _get_memory_context() -> str:
|
||||
def _get_memory_context(agent_name: str | None = None) -> str:
|
||||
"""Get memory context for injection into system prompt.
|
||||
|
||||
Args:
|
||||
agent_name: If provided, loads per-agent memory. If None, loads global memory.
|
||||
|
||||
Returns:
|
||||
Formatted memory context string wrapped in XML tags, or empty string if disabled.
|
||||
"""
|
||||
@@ -294,7 +299,7 @@ def _get_memory_context() -> str:
|
||||
if not config.enabled or not config.injection_enabled:
|
||||
return ""
|
||||
|
||||
memory_data = get_memory_data()
|
||||
memory_data = get_memory_data(agent_name)
|
||||
memory_content = format_memory_for_injection(memory_data, max_tokens=config.max_injection_tokens)
|
||||
|
||||
if not memory_content.strip():
|
||||
@@ -309,7 +314,7 @@ def _get_memory_context() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def get_skills_prompt_section() -> str:
|
||||
def get_skills_prompt_section(available_skills: set[str] | None = None) -> str:
|
||||
"""Generate the skills prompt section with available skills list.
|
||||
|
||||
Returns the <skill_system>...</skill_system> block listing all enabled skills,
|
||||
@@ -328,6 +333,9 @@ def get_skills_prompt_section() -> str:
|
||||
if not skills:
|
||||
return ""
|
||||
|
||||
if available_skills is not None:
|
||||
skills = [skill for skill in skills if skill.name in available_skills]
|
||||
|
||||
skill_items = "\n".join(
|
||||
f" <skill>\n <name>{skill.name}</name>\n <description>{skill.description}</description>\n <location>{skill.get_container_file_path(container_base_path)}</location>\n </skill>" for skill in skills
|
||||
)
|
||||
@@ -350,9 +358,17 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
||||
</skill_system>"""
|
||||
|
||||
|
||||
def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3) -> str:
|
||||
def get_agent_soul(agent_name: str | None) -> str:
|
||||
# Append SOUL.md (agent personality) if present
|
||||
soul = load_agent_soul(agent_name)
|
||||
if soul:
|
||||
return f"<soul>\n{soul}\n</soul>\n" if soul else ""
|
||||
return ""
|
||||
|
||||
|
||||
def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3, *, agent_name: str | None = None, available_skills: set[str] | None = None) -> str:
|
||||
# Get memory context
|
||||
memory_context = _get_memory_context()
|
||||
memory_context = _get_memory_context(agent_name)
|
||||
|
||||
# Include subagent section only if enabled (from runtime parameter)
|
||||
n = max_concurrent_subagents
|
||||
@@ -377,10 +393,12 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen
|
||||
)
|
||||
|
||||
# Get skills section
|
||||
skills_section = get_skills_prompt_section()
|
||||
skills_section = get_skills_prompt_section(available_skills)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
agent_name=agent_name or "DeerFlow 2.0",
|
||||
soul=get_agent_soul(agent_name),
|
||||
skills_section=skills_section,
|
||||
memory_context=memory_context,
|
||||
subagent_section=subagent_section,
|
||||
|
||||
@@ -16,6 +16,7 @@ class ConversationContext:
|
||||
thread_id: str
|
||||
messages: list[Any]
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
agent_name: str | None = None
|
||||
|
||||
|
||||
class MemoryUpdateQueue:
|
||||
@@ -33,12 +34,13 @@ class MemoryUpdateQueue:
|
||||
self._timer: threading.Timer | None = None
|
||||
self._processing = False
|
||||
|
||||
def add(self, thread_id: str, messages: list[Any]) -> None:
|
||||
def add(self, thread_id: str, messages: list[Any], agent_name: str | None = None) -> None:
|
||||
"""Add a conversation to the update queue.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
messages: The conversation messages.
|
||||
agent_name: If provided, memory is stored per-agent. If None, uses global memory.
|
||||
"""
|
||||
config = get_memory_config()
|
||||
if not config.enabled:
|
||||
@@ -47,6 +49,7 @@ class MemoryUpdateQueue:
|
||||
context = ConversationContext(
|
||||
thread_id=thread_id,
|
||||
messages=messages,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
@@ -108,6 +111,7 @@ class MemoryUpdateQueue:
|
||||
success = updater.update_memory(
|
||||
messages=context.messages,
|
||||
thread_id=context.thread_id,
|
||||
agent_name=context.agent_name,
|
||||
)
|
||||
if success:
|
||||
print(f"Memory updated successfully for thread {context.thread_id}")
|
||||
|
||||
@@ -15,8 +15,19 @@ from src.config.paths import get_paths
|
||||
from src.models import create_chat_model
|
||||
|
||||
|
||||
def _get_memory_file_path() -> Path:
|
||||
"""Get the path to the memory file."""
|
||||
def _get_memory_file_path(agent_name: str | None = None) -> Path:
|
||||
"""Get the path to the memory file.
|
||||
|
||||
Args:
|
||||
agent_name: If provided, returns the per-agent memory file path.
|
||||
If None, returns the global memory file path.
|
||||
|
||||
Returns:
|
||||
Path to the memory file.
|
||||
"""
|
||||
if agent_name is not None:
|
||||
return get_paths().agent_memory_file(agent_name)
|
||||
|
||||
config = get_memory_config()
|
||||
if config.storage_path:
|
||||
p = Path(config.storage_path)
|
||||
@@ -44,24 +55,24 @@ def _create_empty_memory() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Global memory data cache
|
||||
_memory_data: dict[str, Any] | None = None
|
||||
# Track file modification time for cache invalidation
|
||||
_memory_file_mtime: float | None = None
|
||||
# Per-agent memory cache: keyed by agent_name (None = global)
|
||||
# Value: (memory_data, file_mtime)
|
||||
_memory_cache: dict[str | None, tuple[dict[str, Any], float | None]] = {}
|
||||
|
||||
|
||||
def get_memory_data() -> dict[str, Any]:
|
||||
def get_memory_data(agent_name: str | None = None) -> dict[str, Any]:
|
||||
"""Get the current memory data (cached with file modification time check).
|
||||
|
||||
The cache is automatically invalidated if the memory file has been modified
|
||||
since the last load, ensuring fresh data is always returned.
|
||||
|
||||
Args:
|
||||
agent_name: If provided, loads per-agent memory. If None, loads global memory.
|
||||
|
||||
Returns:
|
||||
The memory data dictionary.
|
||||
"""
|
||||
global _memory_data, _memory_file_mtime
|
||||
|
||||
file_path = _get_memory_file_path()
|
||||
file_path = _get_memory_file_path(agent_name)
|
||||
|
||||
# Get current file modification time
|
||||
try:
|
||||
@@ -69,41 +80,48 @@ def get_memory_data() -> dict[str, Any]:
|
||||
except OSError:
|
||||
current_mtime = None
|
||||
|
||||
cached = _memory_cache.get(agent_name)
|
||||
|
||||
# Invalidate cache if file has been modified or doesn't exist
|
||||
if _memory_data is None or _memory_file_mtime != current_mtime:
|
||||
_memory_data = _load_memory_from_file()
|
||||
_memory_file_mtime = current_mtime
|
||||
if cached is None or cached[1] != current_mtime:
|
||||
memory_data = _load_memory_from_file(agent_name)
|
||||
_memory_cache[agent_name] = (memory_data, current_mtime)
|
||||
return memory_data
|
||||
|
||||
return _memory_data
|
||||
return cached[0]
|
||||
|
||||
|
||||
def reload_memory_data() -> dict[str, Any]:
|
||||
def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]:
|
||||
"""Reload memory data from file, forcing cache invalidation.
|
||||
|
||||
Args:
|
||||
agent_name: If provided, reloads per-agent memory. If None, reloads global memory.
|
||||
|
||||
Returns:
|
||||
The reloaded memory data dictionary.
|
||||
"""
|
||||
global _memory_data, _memory_file_mtime
|
||||
file_path = _get_memory_file_path(agent_name)
|
||||
memory_data = _load_memory_from_file(agent_name)
|
||||
|
||||
file_path = _get_memory_file_path()
|
||||
_memory_data = _load_memory_from_file()
|
||||
|
||||
# Update file modification time after reload
|
||||
try:
|
||||
_memory_file_mtime = file_path.stat().st_mtime if file_path.exists() else None
|
||||
mtime = file_path.stat().st_mtime if file_path.exists() else None
|
||||
except OSError:
|
||||
_memory_file_mtime = None
|
||||
mtime = None
|
||||
|
||||
return _memory_data
|
||||
_memory_cache[agent_name] = (memory_data, mtime)
|
||||
return memory_data
|
||||
|
||||
|
||||
def _load_memory_from_file() -> dict[str, Any]:
|
||||
def _load_memory_from_file(agent_name: str | None = None) -> dict[str, Any]:
|
||||
"""Load memory data from file.
|
||||
|
||||
Args:
|
||||
agent_name: If provided, loads per-agent memory file. If None, loads global.
|
||||
|
||||
Returns:
|
||||
The memory data dictionary.
|
||||
"""
|
||||
file_path = _get_memory_file_path()
|
||||
file_path = _get_memory_file_path(agent_name)
|
||||
|
||||
if not file_path.exists():
|
||||
return _create_empty_memory()
|
||||
@@ -117,17 +135,17 @@ def _load_memory_from_file() -> dict[str, Any]:
|
||||
return _create_empty_memory()
|
||||
|
||||
|
||||
def _save_memory_to_file(memory_data: dict[str, Any]) -> bool:
|
||||
def _save_memory_to_file(memory_data: dict[str, Any], agent_name: str | None = None) -> bool:
|
||||
"""Save memory data to file and update cache.
|
||||
|
||||
Args:
|
||||
memory_data: The memory data to save.
|
||||
agent_name: If provided, saves to per-agent memory file. If None, saves to global.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
global _memory_data, _memory_file_mtime
|
||||
file_path = _get_memory_file_path()
|
||||
file_path = _get_memory_file_path(agent_name)
|
||||
|
||||
try:
|
||||
# Ensure directory exists
|
||||
@@ -145,11 +163,12 @@ def _save_memory_to_file(memory_data: dict[str, Any]) -> bool:
|
||||
temp_path.replace(file_path)
|
||||
|
||||
# Update cache and file modification time
|
||||
_memory_data = memory_data
|
||||
try:
|
||||
_memory_file_mtime = file_path.stat().st_mtime
|
||||
mtime = file_path.stat().st_mtime
|
||||
except OSError:
|
||||
_memory_file_mtime = None
|
||||
mtime = None
|
||||
|
||||
_memory_cache[agent_name] = (memory_data, mtime)
|
||||
|
||||
print(f"Memory saved to {file_path}")
|
||||
return True
|
||||
@@ -175,12 +194,13 @@ class MemoryUpdater:
|
||||
model_name = self._model_name or config.model_name
|
||||
return create_chat_model(name=model_name, thinking_enabled=False)
|
||||
|
||||
def update_memory(self, messages: list[Any], thread_id: str | None = None) -> bool:
|
||||
def update_memory(self, messages: list[Any], thread_id: str | None = None, agent_name: str | None = None) -> bool:
|
||||
"""Update memory based on conversation messages.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
thread_id: Optional thread ID for tracking source.
|
||||
agent_name: If provided, updates per-agent memory. If None, updates global memory.
|
||||
|
||||
Returns:
|
||||
True if update was successful, False otherwise.
|
||||
@@ -194,7 +214,7 @@ class MemoryUpdater:
|
||||
|
||||
try:
|
||||
# Get current memory
|
||||
current_memory = get_memory_data()
|
||||
current_memory = get_memory_data(agent_name)
|
||||
|
||||
# Format conversation for prompt
|
||||
conversation_text = format_conversation_for_update(messages)
|
||||
@@ -225,7 +245,7 @@ class MemoryUpdater:
|
||||
updated_memory = self._apply_updates(current_memory, update_data, thread_id)
|
||||
|
||||
# Save
|
||||
return _save_memory_to_file(updated_memory)
|
||||
return _save_memory_to_file(updated_memory, agent_name)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Failed to parse LLM response for memory update: {e}")
|
||||
@@ -305,15 +325,16 @@ class MemoryUpdater:
|
||||
return current_memory
|
||||
|
||||
|
||||
def update_memory_from_conversation(messages: list[Any], thread_id: str | None = None) -> bool:
|
||||
def update_memory_from_conversation(messages: list[Any], thread_id: str | None = None, agent_name: str | None = None) -> bool:
|
||||
"""Convenience function to update memory from a conversation.
|
||||
|
||||
Args:
|
||||
messages: List of conversation messages.
|
||||
thread_id: Optional thread ID.
|
||||
agent_name: If provided, updates per-agent memory. If None, updates global memory.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
updater = MemoryUpdater()
|
||||
return updater.update_memory(messages, thread_id)
|
||||
return updater.update_memory(messages, thread_id, agent_name)
|
||||
|
||||
@@ -62,6 +62,15 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
|
||||
state_schema = MemoryMiddlewareState
|
||||
|
||||
def __init__(self, agent_name: str | None = None):
|
||||
"""Initialize the MemoryMiddleware.
|
||||
|
||||
Args:
|
||||
agent_name: If provided, memory is stored per-agent. If None, uses global memory.
|
||||
"""
|
||||
super().__init__()
|
||||
self._agent_name = agent_name
|
||||
|
||||
@override
|
||||
def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
"""Queue conversation for memory update after agent completes.
|
||||
@@ -102,6 +111,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
|
||||
# Queue the filtered conversation for memory update
|
||||
queue = get_memory_queue()
|
||||
queue.add(thread_id=thread_id, messages=filtered_messages)
|
||||
queue.add(thread_id=thread_id, messages=filtered_messages, agent_name=self._agent_name)
|
||||
|
||||
return None
|
||||
|
||||
120
backend/src/config/agents_config.py
Normal file
120
backend/src/config/agents_config.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Configuration and loaders for custom agents."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SOUL_FILENAME = "SOUL.md"
|
||||
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Configuration for a custom agent."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
model: str | None = None
|
||||
tool_groups: list[str] | None = None
|
||||
|
||||
|
||||
def load_agent_config(name: str | None) -> AgentConfig | None:
|
||||
"""Load the custom or default agent's config from its directory.
|
||||
|
||||
Args:
|
||||
name: The agent name.
|
||||
|
||||
Returns:
|
||||
AgentConfig instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the agent directory or config.yaml does not exist.
|
||||
ValueError: If config.yaml cannot be parsed.
|
||||
"""
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if not AGENT_NAME_PATTERN.match(name):
|
||||
raise ValueError(f"Invalid agent name '{name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}")
|
||||
agent_dir = get_paths().agent_dir(name)
|
||||
config_file = agent_dir / "config.yaml"
|
||||
|
||||
if not agent_dir.exists():
|
||||
raise FileNotFoundError(f"Agent directory not found: {agent_dir}")
|
||||
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(f"Agent config not found: {config_file}")
|
||||
|
||||
try:
|
||||
with open(config_file, encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Failed to parse agent config {config_file}: {e}") from e
|
||||
|
||||
# Ensure name is set from directory name if not in file
|
||||
if "name" not in data:
|
||||
data["name"] = name
|
||||
|
||||
# Strip unknown fields before passing to Pydantic (e.g. legacy prompt_file)
|
||||
known_fields = set(AgentConfig.model_fields.keys())
|
||||
data = {k: v for k, v in data.items() if k in known_fields}
|
||||
|
||||
return AgentConfig(**data)
|
||||
|
||||
|
||||
def load_agent_soul(agent_name: str | None) -> str | None:
|
||||
"""Read the SOUL.md file for a custom agent, if it exists.
|
||||
|
||||
SOUL.md defines the agent's personality, values, and behavioral guardrails.
|
||||
It is injected into the lead agent's system prompt as additional context.
|
||||
|
||||
Args:
|
||||
agent_name: The name of the agent or None for the default agent.
|
||||
|
||||
Returns:
|
||||
The SOUL.md content as a string, or None if the file does not exist.
|
||||
"""
|
||||
agent_dir = get_paths().agent_dir(agent_name) if agent_name else get_paths().base_dir
|
||||
soul_path = agent_dir / SOUL_FILENAME
|
||||
if not soul_path.exists():
|
||||
return None
|
||||
content = soul_path.read_text(encoding="utf-8").strip()
|
||||
return content or None
|
||||
|
||||
|
||||
def list_custom_agents() -> list[AgentConfig]:
|
||||
"""Scan the agents directory and return all valid custom agents.
|
||||
|
||||
Returns:
|
||||
List of AgentConfig for each valid agent directory found.
|
||||
"""
|
||||
agents_dir = get_paths().agents_dir
|
||||
|
||||
if not agents_dir.exists():
|
||||
return []
|
||||
|
||||
agents: list[AgentConfig] = []
|
||||
|
||||
for entry in sorted(agents_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
|
||||
config_file = entry / "config.yaml"
|
||||
if not config_file.exists():
|
||||
logger.debug(f"Skipping {entry.name}: no config.yaml")
|
||||
continue
|
||||
|
||||
try:
|
||||
agent_cfg = load_agent_config(entry.name)
|
||||
agents.append(agent_cfg)
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping agent '{entry.name}': {e}")
|
||||
|
||||
return agents
|
||||
@@ -15,6 +15,12 @@ class Paths:
|
||||
Directory layout (host side):
|
||||
{base_dir}/
|
||||
├── memory.json
|
||||
├── USER.md <-- global user profile (injected into all agents)
|
||||
├── agents/
|
||||
│ └── {agent_name}/
|
||||
│ ├── config.yaml
|
||||
│ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt)
|
||||
│ └── memory.json
|
||||
└── threads/
|
||||
└── {thread_id}/
|
||||
└── user-data/ <-- mounted as /mnt/user-data/ inside sandbox
|
||||
@@ -52,6 +58,24 @@ class Paths:
|
||||
"""Path to the persisted memory file: `{base_dir}/memory.json`."""
|
||||
return self.base_dir / "memory.json"
|
||||
|
||||
@property
|
||||
def user_md_file(self) -> Path:
|
||||
"""Path to the global user profile file: `{base_dir}/USER.md`."""
|
||||
return self.base_dir / "USER.md"
|
||||
|
||||
@property
|
||||
def agents_dir(self) -> Path:
|
||||
"""Root directory for all custom agents: `{base_dir}/agents/`."""
|
||||
return self.base_dir / "agents"
|
||||
|
||||
def agent_dir(self, name: str) -> Path:
|
||||
"""Directory for a specific agent: `{base_dir}/agents/{name}/`."""
|
||||
return self.agents_dir / name.lower()
|
||||
|
||||
def agent_memory_file(self, name: str) -> Path:
|
||||
"""Per-agent memory file: `{base_dir}/agents/{name}/memory.json`."""
|
||||
return self.agent_dir(name) / "memory.json"
|
||||
|
||||
def thread_dir(self, thread_id: str) -> Path:
|
||||
"""
|
||||
Host path for a thread's data: `{base_dir}/threads/{thread_id}/`
|
||||
@@ -64,10 +88,7 @@ class Paths:
|
||||
or `..`) that could cause directory traversal.
|
||||
"""
|
||||
if not _SAFE_THREAD_ID_RE.match(thread_id):
|
||||
raise ValueError(
|
||||
f"Invalid thread_id {thread_id!r}: only alphanumeric characters, "
|
||||
"hyphens, and underscores are allowed."
|
||||
)
|
||||
raise ValueError(f"Invalid thread_id {thread_id!r}: only alphanumeric characters, hyphens, and underscores are allowed.")
|
||||
return self.base_dir / "threads" / thread_id
|
||||
|
||||
def sandbox_work_dir(self, thread_id: str) -> Path:
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
|
||||
from src.config.app_config import get_app_config
|
||||
from src.gateway.config import get_gateway_config
|
||||
from src.gateway.routers import artifacts, mcp, memory, models, skills, uploads
|
||||
from src.gateway.routers import agents, artifacts, mcp, memory, models, skills, uploads
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -100,6 +100,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
"name": "uploads",
|
||||
"description": "Upload and manage user files for threads",
|
||||
},
|
||||
{
|
||||
"name": "agents",
|
||||
"description": "Create and manage custom agents with per-agent config and prompts",
|
||||
},
|
||||
{
|
||||
"name": "health",
|
||||
"description": "Health check and system status endpoints",
|
||||
@@ -128,6 +132,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||||
app.include_router(uploads.router)
|
||||
|
||||
# Agents API is mounted at /api/agents
|
||||
app.include_router(agents.router)
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint.
|
||||
|
||||
383
backend/src/gateway/routers/agents.py
Normal file
383
backend/src/gateway/routers/agents.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""CRUD API for custom agents."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul
|
||||
from src.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["agents"])
|
||||
|
||||
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
"""Response model for a custom agent."""
|
||||
|
||||
name: str = Field(..., description="Agent name (hyphen-case)")
|
||||
description: str = Field(default="", description="Agent description")
|
||||
model: str | None = Field(default=None, description="Optional model override")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
||||
soul: str | None = Field(default=None, description="SOUL.md content (included on GET /{name})")
|
||||
|
||||
|
||||
class AgentsListResponse(BaseModel):
|
||||
"""Response model for listing all custom agents."""
|
||||
|
||||
agents: list[AgentResponse]
|
||||
|
||||
|
||||
class AgentCreateRequest(BaseModel):
|
||||
"""Request body for creating a custom agent."""
|
||||
|
||||
name: str = Field(..., description="Agent name (must match ^[A-Za-z0-9-]+$, stored as lowercase)")
|
||||
description: str = Field(default="", description="Agent description")
|
||||
model: str | None = Field(default=None, description="Optional model override")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
||||
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
"""Request body for updating a custom agent."""
|
||||
|
||||
description: str | None = Field(default=None, description="Updated description")
|
||||
model: str | None = Field(default=None, description="Updated model override")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||
|
||||
|
||||
def _validate_agent_name(name: str) -> None:
|
||||
"""Validate agent name against allowed pattern.
|
||||
|
||||
Args:
|
||||
name: The agent name to validate.
|
||||
|
||||
Raises:
|
||||
HTTPException: 422 if the name is invalid.
|
||||
"""
|
||||
if not AGENT_NAME_PATTERN.match(name):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid agent name '{name}'. Must match ^[A-Za-z0-9-]+$ (letters, digits, and hyphens only).",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_agent_name(name: str) -> str:
|
||||
"""Normalize agent name to lowercase for filesystem storage."""
|
||||
return name.lower()
|
||||
|
||||
|
||||
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse:
|
||||
"""Convert AgentConfig to AgentResponse."""
|
||||
soul: str | None = None
|
||||
if include_soul:
|
||||
soul = load_agent_soul(agent_cfg.name) or ""
|
||||
|
||||
return AgentResponse(
|
||||
name=agent_cfg.name,
|
||||
description=agent_cfg.description,
|
||||
model=agent_cfg.model,
|
||||
tool_groups=agent_cfg.tool_groups,
|
||||
soul=soul,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agents",
|
||||
response_model=AgentsListResponse,
|
||||
summary="List Custom Agents",
|
||||
description="List all custom agents available in the agents directory.",
|
||||
)
|
||||
async def list_agents() -> AgentsListResponse:
|
||||
"""List all custom agents.
|
||||
|
||||
Returns:
|
||||
List of all custom agents with their metadata (without soul content).
|
||||
"""
|
||||
try:
|
||||
agents = list_custom_agents()
|
||||
return AgentsListResponse(agents=[_agent_config_to_response(a) for a in agents])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agents/check",
|
||||
summary="Check Agent Name",
|
||||
description="Validate an agent name and check if it is available (case-insensitive).",
|
||||
)
|
||||
async def check_agent_name(name: str) -> dict:
|
||||
"""Check whether an agent name is valid and not yet taken.
|
||||
|
||||
Args:
|
||||
name: The agent name to check.
|
||||
|
||||
Returns:
|
||||
``{"available": true/false, "name": "<normalized>"}``
|
||||
|
||||
Raises:
|
||||
HTTPException: 422 if the name is invalid.
|
||||
"""
|
||||
_validate_agent_name(name)
|
||||
normalized = _normalize_agent_name(name)
|
||||
available = not get_paths().agent_dir(normalized).exists()
|
||||
return {"available": available, "name": normalized}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agents/{name}",
|
||||
response_model=AgentResponse,
|
||||
summary="Get Custom Agent",
|
||||
description="Retrieve details and SOUL.md content for a specific custom agent.",
|
||||
)
|
||||
async def get_agent(name: str) -> AgentResponse:
|
||||
"""Get a specific custom agent by name.
|
||||
|
||||
Args:
|
||||
name: The agent name.
|
||||
|
||||
Returns:
|
||||
Agent details including SOUL.md content.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if agent not found.
|
||||
"""
|
||||
_validate_agent_name(name)
|
||||
name = _normalize_agent_name(name)
|
||||
|
||||
try:
|
||||
agent_cfg = load_agent_config(name)
|
||||
return _agent_config_to_response(agent_cfg, include_soul=True)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get agent '{name}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agents",
|
||||
response_model=AgentResponse,
|
||||
status_code=201,
|
||||
summary="Create Custom Agent",
|
||||
description="Create a new custom agent with its config and SOUL.md.",
|
||||
)
|
||||
async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
||||
"""Create a new custom agent.
|
||||
|
||||
Args:
|
||||
request: The agent creation request.
|
||||
|
||||
Returns:
|
||||
The created agent details.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
||||
"""
|
||||
_validate_agent_name(request.name)
|
||||
normalized_name = _normalize_agent_name(request.name)
|
||||
|
||||
agent_dir = get_paths().agent_dir(normalized_name)
|
||||
|
||||
if agent_dir.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
||||
|
||||
try:
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write config.yaml
|
||||
config_data: dict = {"name": normalized_name}
|
||||
if request.description:
|
||||
config_data["description"] = request.description
|
||||
if request.model is not None:
|
||||
config_data["model"] = request.model
|
||||
if request.tool_groups is not None:
|
||||
config_data["tool_groups"] = request.tool_groups
|
||||
|
||||
config_file = agent_dir / "config.yaml"
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
# Write SOUL.md
|
||||
soul_file = agent_dir / "SOUL.md"
|
||||
soul_file.write_text(request.soul, encoding="utf-8")
|
||||
|
||||
logger.info(f"Created agent '{normalized_name}' at {agent_dir}")
|
||||
|
||||
agent_cfg = load_agent_config(normalized_name)
|
||||
return _agent_config_to_response(agent_cfg, include_soul=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Clean up on failure
|
||||
if agent_dir.exists():
|
||||
shutil.rmtree(agent_dir)
|
||||
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/agents/{name}",
|
||||
response_model=AgentResponse,
|
||||
summary="Update Custom Agent",
|
||||
description="Update an existing custom agent's config and/or SOUL.md.",
|
||||
)
|
||||
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
||||
"""Update an existing custom agent.
|
||||
|
||||
Args:
|
||||
name: The agent name.
|
||||
request: The update request (all fields optional).
|
||||
|
||||
Returns:
|
||||
The updated agent details.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if agent not found.
|
||||
"""
|
||||
_validate_agent_name(name)
|
||||
name = _normalize_agent_name(name)
|
||||
|
||||
try:
|
||||
agent_cfg = load_agent_config(name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||
|
||||
agent_dir = get_paths().agent_dir(name)
|
||||
|
||||
try:
|
||||
# Update config if any config fields changed
|
||||
config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups])
|
||||
|
||||
if config_changed:
|
||||
updated: dict = {
|
||||
"name": agent_cfg.name,
|
||||
"description": request.description if request.description is not None else agent_cfg.description,
|
||||
}
|
||||
new_model = request.model if request.model is not None else agent_cfg.model
|
||||
if new_model is not None:
|
||||
updated["model"] = new_model
|
||||
|
||||
new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups
|
||||
if new_tool_groups is not None:
|
||||
updated["tool_groups"] = new_tool_groups
|
||||
|
||||
config_file = agent_dir / "config.yaml"
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
# Update SOUL.md if provided
|
||||
if request.soul is not None:
|
||||
soul_path = agent_dir / "SOUL.md"
|
||||
soul_path.write_text(request.soul, encoding="utf-8")
|
||||
|
||||
logger.info(f"Updated agent '{name}'")
|
||||
|
||||
refreshed_cfg = load_agent_config(name)
|
||||
return _agent_config_to_response(refreshed_cfg, include_soul=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update agent '{name}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}")
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
"""Response model for the global user profile (USER.md)."""
|
||||
|
||||
content: str | None = Field(default=None, description="USER.md content, or null if not yet created")
|
||||
|
||||
|
||||
class UserProfileUpdateRequest(BaseModel):
|
||||
"""Request body for setting the global user profile."""
|
||||
|
||||
content: str = Field(default="", description="USER.md content — describes the user's background and preferences")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-profile",
|
||||
response_model=UserProfileResponse,
|
||||
summary="Get User Profile",
|
||||
description="Read the global USER.md file that is injected into all custom agents.",
|
||||
)
|
||||
async def get_user_profile() -> UserProfileResponse:
|
||||
"""Return the current USER.md content.
|
||||
|
||||
Returns:
|
||||
UserProfileResponse with content=None if USER.md does not exist yet.
|
||||
"""
|
||||
try:
|
||||
user_md_path = get_paths().user_md_file
|
||||
if not user_md_path.exists():
|
||||
return UserProfileResponse(content=None)
|
||||
raw = user_md_path.read_text(encoding="utf-8").strip()
|
||||
return UserProfileResponse(content=raw or None)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read user profile: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/user-profile",
|
||||
response_model=UserProfileResponse,
|
||||
summary="Update User Profile",
|
||||
description="Write the global USER.md file that is injected into all custom agents.",
|
||||
)
|
||||
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
|
||||
"""Create or overwrite the global USER.md.
|
||||
|
||||
Args:
|
||||
request: The update request with the new USER.md content.
|
||||
|
||||
Returns:
|
||||
UserProfileResponse with the saved content.
|
||||
"""
|
||||
try:
|
||||
paths = get_paths()
|
||||
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths.user_md_file.write_text(request.content, encoding="utf-8")
|
||||
logger.info(f"Updated USER.md at {paths.user_md_file}")
|
||||
return UserProfileResponse(content=request.content or None)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update user profile: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/agents/{name}",
|
||||
status_code=204,
|
||||
summary="Delete Custom Agent",
|
||||
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
|
||||
)
|
||||
async def delete_agent(name: str) -> None:
|
||||
"""Delete a custom agent.
|
||||
|
||||
Args:
|
||||
name: The agent name.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if agent not found.
|
||||
"""
|
||||
_validate_agent_name(name)
|
||||
name = _normalize_agent_name(name)
|
||||
|
||||
agent_dir = get_paths().agent_dir(name)
|
||||
|
||||
if not agent_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
||||
|
||||
try:
|
||||
shutil.rmtree(agent_dir)
|
||||
logger.info(f"Deleted agent '{name}' from {agent_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
|
||||
@@ -1,9 +1,11 @@
|
||||
from .clarification_tool import ask_clarification_tool
|
||||
from .present_file_tool import present_file_tool
|
||||
from .setup_agent_tool import setup_agent
|
||||
from .task_tool import task_tool
|
||||
from .view_image_tool import view_image_tool
|
||||
|
||||
__all__ = [
|
||||
"setup_agent",
|
||||
"present_file_tool",
|
||||
"ask_clarification_tool",
|
||||
"view_image_tool",
|
||||
|
||||
62
backend/src/tools/builtins/setup_agent_tool.py
Normal file
62
backend/src/tools/builtins/setup_agent_tool.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.prebuilt import ToolRuntime
|
||||
from langgraph.types import Command
|
||||
|
||||
from src.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool
|
||||
def setup_agent(
|
||||
soul: str,
|
||||
description: str,
|
||||
runtime: ToolRuntime,
|
||||
) -> Command:
|
||||
"""Setup the custom DeerFlow agent.
|
||||
|
||||
Args:
|
||||
soul: Full SOUL.md content defining the agent's personality and behavior.
|
||||
description: One-line description of what the agent does.
|
||||
"""
|
||||
|
||||
agent_name: str | None = runtime.context.get("agent_name")
|
||||
|
||||
try:
|
||||
paths = get_paths()
|
||||
agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if agent_name:
|
||||
# If agent_name is provided, we are creating a custom agent in the agents/ directory
|
||||
config_data: dict = {"name": agent_name}
|
||||
if description:
|
||||
config_data["description"] = description
|
||||
|
||||
config_file = agent_dir / "config.yaml"
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
soul_file = agent_dir / "SOUL.md"
|
||||
soul_file.write_text(soul, encoding="utf-8")
|
||||
|
||||
logger.info(f"[agent_creator] Created agent '{agent_name}' at {agent_dir}")
|
||||
return Command(
|
||||
update={
|
||||
"created_agent_name": agent_name,
|
||||
"messages": [ToolMessage(content=f"Agent '{agent_name}' created successfully!", tool_call_id=runtime.tool_call_id)],
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import shutil
|
||||
|
||||
if agent_name and agent_dir.exists():
|
||||
# Cleanup the custom agent directory only if it was created but an error occurred during setup
|
||||
shutil.rmtree(agent_dir)
|
||||
logger.error(f"[agent_creator] Failed to create agent '{agent_name}': {e}", exc_info=True)
|
||||
return Command(update={"messages": [ToolMessage(content=f"Error: {e}", tool_call_id=runtime.tool_call_id)]})
|
||||
@@ -226,13 +226,12 @@ class TestLiveFileUpload:
|
||||
# ===========================================================================
|
||||
|
||||
class TestLiveConfigQueries:
|
||||
def test_list_models_returns_ark(self, client):
|
||||
"""list_models() returns the configured ARK model."""
|
||||
def test_list_models_returns_configured_model(self, client):
|
||||
"""list_models() returns at least one configured model with Gateway-aligned fields."""
|
||||
result = client.list_models()
|
||||
assert "models" in result
|
||||
assert len(result["models"]) >= 1
|
||||
names = [m["name"] for m in result["models"]]
|
||||
assert "ark-model" in names
|
||||
# Verify Gateway-aligned fields
|
||||
for m in result["models"]:
|
||||
assert "display_name" in m
|
||||
@@ -240,10 +239,12 @@ class TestLiveConfigQueries:
|
||||
print(f" models: {names}")
|
||||
|
||||
def test_get_model_found(self, client):
|
||||
"""get_model() returns details for existing model."""
|
||||
model = client.get_model("ark-model")
|
||||
"""get_model() returns details for the first configured model."""
|
||||
result = client.list_models()
|
||||
first_model_name = result["models"][0]["name"]
|
||||
model = client.get_model(first_model_name)
|
||||
assert model is not None
|
||||
assert model["name"] == "ark-model"
|
||||
assert model["name"] == first_model_name
|
||||
assert "display_name" in model
|
||||
assert "supports_thinking" in model
|
||||
print(f" model detail: {model}")
|
||||
|
||||
515
backend/tests/test_custom_agent.py
Normal file
515
backend/tests/test_custom_agent.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""Tests for custom agent support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_paths(base_dir: Path):
|
||||
"""Return a Paths instance pointing to base_dir."""
|
||||
from src.config.paths import Paths
|
||||
|
||||
return Paths(base_dir=base_dir)
|
||||
|
||||
|
||||
def _write_agent(base_dir: Path, name: str, config: dict, soul: str = "You are helpful.") -> None:
|
||||
"""Write an agent directory with config.yaml and SOUL.md."""
|
||||
agent_dir = base_dir / "agents" / name
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_copy = dict(config)
|
||||
if "name" not in config_copy:
|
||||
config_copy["name"] = name
|
||||
|
||||
with open(agent_dir / "config.yaml", "w") as f:
|
||||
yaml.dump(config_copy, f)
|
||||
|
||||
(agent_dir / "SOUL.md").write_text(soul, encoding="utf-8")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. Paths class – agent path methods
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestPaths:
|
||||
def test_agents_dir(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
assert paths.agents_dir == tmp_path / "agents"
|
||||
|
||||
def test_agent_dir(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
assert paths.agent_dir("code-reviewer") == tmp_path / "agents" / "code-reviewer"
|
||||
|
||||
def test_agent_memory_file(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
assert paths.agent_memory_file("code-reviewer") == tmp_path / "agents" / "code-reviewer" / "memory.json"
|
||||
|
||||
def test_user_md_file(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
assert paths.user_md_file == tmp_path / "USER.md"
|
||||
|
||||
def test_paths_are_different_from_global(self, tmp_path):
|
||||
paths = _make_paths(tmp_path)
|
||||
assert paths.memory_file != paths.agent_memory_file("my-agent")
|
||||
assert paths.memory_file == tmp_path / "memory.json"
|
||||
assert paths.agent_memory_file("my-agent") == tmp_path / "agents" / "my-agent" / "memory.json"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. AgentConfig – Pydantic parsing
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestAgentConfig:
|
||||
def test_minimal_config(self):
|
||||
from src.config.agents_config import AgentConfig
|
||||
|
||||
cfg = AgentConfig(name="my-agent")
|
||||
assert cfg.name == "my-agent"
|
||||
assert cfg.description == ""
|
||||
assert cfg.model is None
|
||||
assert cfg.tool_groups is None
|
||||
|
||||
def test_full_config(self):
|
||||
from src.config.agents_config import AgentConfig
|
||||
|
||||
cfg = AgentConfig(
|
||||
name="code-reviewer",
|
||||
description="Specialized for code review",
|
||||
model="deepseek-v3",
|
||||
tool_groups=["file:read", "bash"],
|
||||
)
|
||||
assert cfg.name == "code-reviewer"
|
||||
assert cfg.model == "deepseek-v3"
|
||||
assert cfg.tool_groups == ["file:read", "bash"]
|
||||
|
||||
def test_config_from_dict(self):
|
||||
from src.config.agents_config import AgentConfig
|
||||
|
||||
data = {"name": "test-agent", "description": "A test", "model": "gpt-4"}
|
||||
cfg = AgentConfig(**data)
|
||||
assert cfg.name == "test-agent"
|
||||
assert cfg.model == "gpt-4"
|
||||
assert cfg.tool_groups is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. load_agent_config
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestLoadAgentConfig:
|
||||
def test_load_valid_config(self, tmp_path):
|
||||
config_dict = {"name": "code-reviewer", "description": "Code review agent", "model": "deepseek-v3"}
|
||||
_write_agent(tmp_path, "code-reviewer", config_dict)
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config("code-reviewer")
|
||||
|
||||
assert cfg.name == "code-reviewer"
|
||||
assert cfg.description == "Code review agent"
|
||||
assert cfg.model == "deepseek-v3"
|
||||
|
||||
def test_load_missing_agent_raises(self, tmp_path):
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import load_agent_config
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_agent_config("nonexistent-agent")
|
||||
|
||||
def test_load_missing_config_yaml_raises(self, tmp_path):
|
||||
# Create directory without config.yaml
|
||||
(tmp_path / "agents" / "broken-agent").mkdir(parents=True)
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import load_agent_config
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_agent_config("broken-agent")
|
||||
|
||||
def test_load_config_infers_name_from_dir(self, tmp_path):
|
||||
"""Config without 'name' field should use directory name."""
|
||||
agent_dir = tmp_path / "agents" / "inferred-name"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "config.yaml").write_text("description: My agent\n")
|
||||
(agent_dir / "SOUL.md").write_text("Hello")
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config("inferred-name")
|
||||
|
||||
assert cfg.name == "inferred-name"
|
||||
|
||||
def test_load_config_with_tool_groups(self, tmp_path):
|
||||
config_dict = {"name": "restricted", "tool_groups": ["file:read", "file:write"]}
|
||||
_write_agent(tmp_path, "restricted", config_dict)
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config("restricted")
|
||||
|
||||
assert cfg.tool_groups == ["file:read", "file:write"]
|
||||
|
||||
def test_legacy_prompt_file_field_ignored(self, tmp_path):
|
||||
"""Unknown fields like the old prompt_file should be silently ignored."""
|
||||
agent_dir = tmp_path / "agents" / "legacy-agent"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "config.yaml").write_text("name: legacy-agent\nprompt_file: system.md\n")
|
||||
(agent_dir / "SOUL.md").write_text("Soul content")
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config("legacy-agent")
|
||||
|
||||
assert cfg.name == "legacy-agent"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. load_agent_soul
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestLoadAgentSoul:
|
||||
def test_reads_soul_file(self, tmp_path):
|
||||
expected_soul = "You are a specialized code review expert."
|
||||
_write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul=expected_soul)
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import AgentConfig, load_agent_soul
|
||||
|
||||
cfg = AgentConfig(name="code-reviewer")
|
||||
soul = load_agent_soul(cfg.name)
|
||||
|
||||
assert soul == expected_soul
|
||||
|
||||
def test_missing_soul_file_returns_none(self, tmp_path):
|
||||
agent_dir = tmp_path / "agents" / "no-soul"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "config.yaml").write_text("name: no-soul\n")
|
||||
# No SOUL.md created
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import AgentConfig, load_agent_soul
|
||||
|
||||
cfg = AgentConfig(name="no-soul")
|
||||
soul = load_agent_soul(cfg.name)
|
||||
|
||||
assert soul is None
|
||||
|
||||
def test_empty_soul_file_returns_none(self, tmp_path):
|
||||
agent_dir = tmp_path / "agents" / "empty-soul"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "config.yaml").write_text("name: empty-soul\n")
|
||||
(agent_dir / "SOUL.md").write_text(" \n ")
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import AgentConfig, load_agent_soul
|
||||
|
||||
cfg = AgentConfig(name="empty-soul")
|
||||
soul = load_agent_soul(cfg.name)
|
||||
|
||||
assert soul is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. list_custom_agents
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestListCustomAgents:
|
||||
def test_empty_when_no_agents_dir(self, tmp_path):
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import list_custom_agents
|
||||
|
||||
agents = list_custom_agents()
|
||||
|
||||
assert agents == []
|
||||
|
||||
def test_discovers_multiple_agents(self, tmp_path):
|
||||
_write_agent(tmp_path, "agent-a", {"name": "agent-a"})
|
||||
_write_agent(tmp_path, "agent-b", {"name": "agent-b", "description": "B"})
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import list_custom_agents
|
||||
|
||||
agents = list_custom_agents()
|
||||
|
||||
names = [a.name for a in agents]
|
||||
assert "agent-a" in names
|
||||
assert "agent-b" in names
|
||||
|
||||
def test_skips_dirs_without_config_yaml(self, tmp_path):
|
||||
# Valid agent
|
||||
_write_agent(tmp_path, "valid-agent", {"name": "valid-agent"})
|
||||
# Invalid dir (no config.yaml)
|
||||
(tmp_path / "agents" / "invalid-dir").mkdir(parents=True)
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import list_custom_agents
|
||||
|
||||
agents = list_custom_agents()
|
||||
|
||||
assert len(agents) == 1
|
||||
assert agents[0].name == "valid-agent"
|
||||
|
||||
def test_skips_non_directory_entries(self, tmp_path):
|
||||
# Create the agents dir with a file (not a dir)
|
||||
agents_dir = tmp_path / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / "not-a-dir.txt").write_text("hello")
|
||||
_write_agent(tmp_path, "real-agent", {"name": "real-agent"})
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import list_custom_agents
|
||||
|
||||
agents = list_custom_agents()
|
||||
|
||||
assert len(agents) == 1
|
||||
assert agents[0].name == "real-agent"
|
||||
|
||||
def test_returns_sorted_by_name(self, tmp_path):
|
||||
_write_agent(tmp_path, "z-agent", {"name": "z-agent"})
|
||||
_write_agent(tmp_path, "a-agent", {"name": "a-agent"})
|
||||
_write_agent(tmp_path, "m-agent", {"name": "m-agent"})
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)):
|
||||
from src.config.agents_config import list_custom_agents
|
||||
|
||||
agents = list_custom_agents()
|
||||
|
||||
names = [a.name for a in agents]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. Memory isolation: _get_memory_file_path
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestMemoryFilePath:
|
||||
def test_global_memory_path(self, tmp_path):
|
||||
"""None agent_name should return global memory file."""
|
||||
import src.agents.memory.updater as updater_mod
|
||||
|
||||
with patch("src.agents.memory.updater.get_paths", return_value=_make_paths(tmp_path)):
|
||||
path = updater_mod._get_memory_file_path(None)
|
||||
assert path == tmp_path / "memory.json"
|
||||
|
||||
def test_agent_memory_path(self, tmp_path):
|
||||
"""Providing agent_name should return per-agent memory file."""
|
||||
import src.agents.memory.updater as updater_mod
|
||||
|
||||
with patch("src.agents.memory.updater.get_paths", return_value=_make_paths(tmp_path)):
|
||||
path = updater_mod._get_memory_file_path("code-reviewer")
|
||||
assert path == tmp_path / "agents" / "code-reviewer" / "memory.json"
|
||||
|
||||
def test_different_paths_for_different_agents(self, tmp_path):
|
||||
import src.agents.memory.updater as updater_mod
|
||||
|
||||
with patch("src.agents.memory.updater.get_paths", return_value=_make_paths(tmp_path)):
|
||||
path_global = updater_mod._get_memory_file_path(None)
|
||||
path_a = updater_mod._get_memory_file_path("agent-a")
|
||||
path_b = updater_mod._get_memory_file_path("agent-b")
|
||||
|
||||
assert path_global != path_a
|
||||
assert path_global != path_b
|
||||
assert path_a != path_b
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. Gateway API – Agents endpoints
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _make_test_app(tmp_path: Path):
|
||||
"""Create a FastAPI app with the agents router, patching paths to tmp_path."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from src.gateway.routers.agents import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_client(tmp_path):
|
||||
"""TestClient with agents router, using tmp_path as base_dir."""
|
||||
paths_instance = _make_paths(tmp_path)
|
||||
|
||||
with patch("src.config.agents_config.get_paths", return_value=paths_instance), patch("src.gateway.routers.agents.get_paths", return_value=paths_instance):
|
||||
app = _make_test_app(tmp_path)
|
||||
with TestClient(app) as client:
|
||||
client._tmp_path = tmp_path # type: ignore[attr-defined]
|
||||
yield client
|
||||
|
||||
|
||||
class TestAgentsAPI:
|
||||
def test_list_agents_empty(self, agent_client):
|
||||
response = agent_client.get("/api/agents")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["agents"] == []
|
||||
|
||||
def test_create_agent(self, agent_client):
|
||||
payload = {
|
||||
"name": "code-reviewer",
|
||||
"description": "Reviews code",
|
||||
"soul": "You are a code reviewer.",
|
||||
}
|
||||
response = agent_client.post("/api/agents", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "code-reviewer"
|
||||
assert data["description"] == "Reviews code"
|
||||
assert data["soul"] == "You are a code reviewer."
|
||||
|
||||
def test_create_agent_invalid_name(self, agent_client):
|
||||
payload = {"name": "Code Reviewer!", "soul": "test"}
|
||||
response = agent_client.post("/api/agents", json=payload)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_duplicate_agent_409(self, agent_client):
|
||||
payload = {"name": "my-agent", "soul": "test"}
|
||||
agent_client.post("/api/agents", json=payload)
|
||||
|
||||
# Second create should fail
|
||||
response = agent_client.post("/api/agents", json=payload)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_list_agents_after_create(self, agent_client):
|
||||
agent_client.post("/api/agents", json={"name": "agent-one", "soul": "p1"})
|
||||
agent_client.post("/api/agents", json={"name": "agent-two", "soul": "p2"})
|
||||
|
||||
response = agent_client.get("/api/agents")
|
||||
assert response.status_code == 200
|
||||
names = [a["name"] for a in response.json()["agents"]]
|
||||
assert "agent-one" in names
|
||||
assert "agent-two" in names
|
||||
|
||||
def test_get_agent(self, agent_client):
|
||||
agent_client.post("/api/agents", json={"name": "test-agent", "soul": "Hello world"})
|
||||
|
||||
response = agent_client.get("/api/agents/test-agent")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "test-agent"
|
||||
assert data["soul"] == "Hello world"
|
||||
|
||||
def test_get_missing_agent_404(self, agent_client):
|
||||
response = agent_client.get("/api/agents/nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_agent_soul(self, agent_client):
|
||||
agent_client.post("/api/agents", json={"name": "update-me", "soul": "original"})
|
||||
|
||||
response = agent_client.put("/api/agents/update-me", json={"soul": "updated"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["soul"] == "updated"
|
||||
|
||||
def test_update_agent_description(self, agent_client):
|
||||
agent_client.post("/api/agents", json={"name": "desc-agent", "description": "old desc", "soul": "p"})
|
||||
|
||||
response = agent_client.put("/api/agents/desc-agent", json={"description": "new desc"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["description"] == "new desc"
|
||||
|
||||
def test_update_missing_agent_404(self, agent_client):
|
||||
response = agent_client.put("/api/agents/ghost-agent", json={"soul": "new"})
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_agent(self, agent_client):
|
||||
agent_client.post("/api/agents", json={"name": "del-me", "soul": "bye"})
|
||||
|
||||
response = agent_client.delete("/api/agents/del-me")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
response = agent_client.get("/api/agents/del-me")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_missing_agent_404(self, agent_client):
|
||||
response = agent_client.delete("/api/agents/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_create_agent_with_model_and_tool_groups(self, agent_client):
|
||||
payload = {
|
||||
"name": "specialized",
|
||||
"description": "Specialized agent",
|
||||
"model": "deepseek-v3",
|
||||
"tool_groups": ["file:read", "bash"],
|
||||
"soul": "You are specialized.",
|
||||
}
|
||||
response = agent_client.post("/api/agents", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["model"] == "deepseek-v3"
|
||||
assert data["tool_groups"] == ["file:read", "bash"]
|
||||
|
||||
def test_create_persists_files_on_disk(self, agent_client, tmp_path):
|
||||
agent_client.post("/api/agents", json={"name": "disk-check", "soul": "disk soul"})
|
||||
|
||||
agent_dir = tmp_path / "agents" / "disk-check"
|
||||
assert agent_dir.exists()
|
||||
assert (agent_dir / "config.yaml").exists()
|
||||
assert (agent_dir / "SOUL.md").exists()
|
||||
assert (agent_dir / "SOUL.md").read_text() == "disk soul"
|
||||
|
||||
def test_delete_removes_files_from_disk(self, agent_client, tmp_path):
|
||||
agent_client.post("/api/agents", json={"name": "remove-me", "soul": "bye"})
|
||||
agent_dir = tmp_path / "agents" / "remove-me"
|
||||
assert agent_dir.exists()
|
||||
|
||||
agent_client.delete("/api/agents/remove-me")
|
||||
assert not agent_dir.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9. Gateway API – User Profile endpoints
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestUserProfileAPI:
|
||||
def test_get_user_profile_empty(self, agent_client):
|
||||
response = agent_client.get("/api/user-profile")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] is None
|
||||
|
||||
def test_put_user_profile(self, agent_client, tmp_path):
|
||||
content = "# User Profile\n\nI am a developer."
|
||||
response = agent_client.put("/api/user-profile", json={"content": content})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == content
|
||||
|
||||
# File should be written to disk
|
||||
user_md = tmp_path / "USER.md"
|
||||
assert user_md.exists()
|
||||
assert user_md.read_text(encoding="utf-8") == content
|
||||
|
||||
def test_get_user_profile_after_put(self, agent_client):
|
||||
content = "# Profile\n\nI work on data science."
|
||||
agent_client.put("/api/user-profile", json={"content": content})
|
||||
|
||||
response = agent_client.get("/api/user-profile")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == content
|
||||
|
||||
def test_put_empty_user_profile_returns_none(self, agent_client):
|
||||
response = agent_client.put("/api/user-profile", json={"content": ""})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] is None
|
||||
@@ -80,7 +80,7 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
||||
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
|
||||
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name: [])
|
||||
monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None: [])
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
|
||||
@@ -120,6 +120,16 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Custom API: Agents endpoint
|
||||
location /api/agents {
|
||||
proxy_pass http://gateway;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Custom API: Artifacts endpoint
|
||||
location ~ ^/api/threads/[^/]+/artifacts {
|
||||
proxy_pass http://gateway;
|
||||
|
||||
@@ -116,6 +116,16 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Custom API: Agents endpoint
|
||||
location /api/agents {
|
||||
proxy_pass http://gateway;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Custom API: Artifacts endpoint
|
||||
location ~ ^/api/threads/[^/]+/artifacts {
|
||||
proxy_pass http://gateway;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
|
||||
export default function AgentChatLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const { agent_name, thread_id: threadIdFromPath } = useParams<{
|
||||
agent_name: string;
|
||||
thread_id: string;
|
||||
}>();
|
||||
|
||||
const { agent } = useAgent(agent_name);
|
||||
|
||||
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: threadIdFromPath !== "new" ? threadIdFromPath : undefined,
|
||||
context: { ...settings.context, agent_name: agent_name },
|
||||
onStart: () => {
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/workspace/agents/${agent_name}/chats/${threadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
void sendMessage(threadId, message, { agent_name });
|
||||
},
|
||||
[sendMessage, threadId, agent_name],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
|
||||
isNewThread
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{agent?.name ?? agent_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div>
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isNewThread && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isNewThread && "-translate-y-[calc(50vh-96px)]",
|
||||
isNewThread
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
autoFocus={isNewThread}
|
||||
status={thread.isLoading ? "streaming" : "ready"}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isNewThread && (
|
||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
251
frontend/src/app/workspace/agents/new/page.tsx
Normal file
251
frontend/src/app/workspace/agents/new/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { checkAgentName, getAgent } from "@/core/agents/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "name" | "chat";
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9-]+$/;
|
||||
|
||||
export default function NewAgentPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// ── Step 1: name form ──────────────────────────────────────────────────────
|
||||
const [step, setStep] = useState<Step>("name");
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [isCheckingName, setIsCheckingName] = useState(false);
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
// ── Step 2: chat ───────────────────────────────────────────────────────────
|
||||
|
||||
// Stable thread ID — all turns belong to the same thread
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
context: {
|
||||
mode: "flash",
|
||||
is_bootstrap: true,
|
||||
},
|
||||
onToolEnd({ name }) {
|
||||
if (name !== "setup_agent" || !agentName) return;
|
||||
getAgent(agentName)
|
||||
.then((fetched) => setAgent(fetched))
|
||||
.catch(() => {
|
||||
// agent write may not be flushed yet — ignore silently
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleConfirmName = useCallback(async () => {
|
||||
const trimmed = nameInput.trim();
|
||||
if (!trimmed) return;
|
||||
if (!NAME_RE.test(trimmed)) {
|
||||
setNameError(t.agents.nameStepInvalidError);
|
||||
return;
|
||||
}
|
||||
setNameError("");
|
||||
setIsCheckingName(true);
|
||||
try {
|
||||
const result = await checkAgentName(trimmed);
|
||||
if (!result.available) {
|
||||
setNameError(t.agents.nameStepAlreadyExistsError);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setNameError(t.agents.nameStepCheckError);
|
||||
return;
|
||||
} finally {
|
||||
setIsCheckingName(false);
|
||||
}
|
||||
setAgentName(trimmed);
|
||||
setStep("chat");
|
||||
await sendMessage(threadId, {
|
||||
text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed),
|
||||
files: [],
|
||||
});
|
||||
}, [
|
||||
nameInput,
|
||||
sendMessage,
|
||||
threadId,
|
||||
t.agents.nameStepBootstrapMessage,
|
||||
t.agents.nameStepInvalidError,
|
||||
t.agents.nameStepAlreadyExistsError,
|
||||
t.agents.nameStepCheckError,
|
||||
]);
|
||||
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleConfirmName();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || thread.isLoading) return;
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: trimmed, files: [] },
|
||||
{ agent_name: agentName },
|
||||
);
|
||||
},
|
||||
[thread.isLoading, sendMessage, threadId, agentName],
|
||||
);
|
||||
|
||||
// ── Shared header ──────────────────────────────────────────────────────────
|
||||
|
||||
const header = (
|
||||
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
|
||||
</header>
|
||||
);
|
||||
|
||||
// ── Step 1: name form ──────────────────────────────────────────────────────
|
||||
|
||||
if (step === "name") {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{header}
|
||||
<main className="flex flex-1 flex-col items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-7 w-7" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t.agents.nameStepTitle}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.agents.nameStepHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t.agents.nameStepPlaceholder}
|
||||
value={nameInput}
|
||||
onChange={(e) => {
|
||||
setNameInput(e.target.value);
|
||||
setNameError("");
|
||||
}}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
className={cn(nameError && "border-destructive")}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-destructive text-sm">{nameError}</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleConfirmName()}
|
||||
disabled={!nameInput.trim() || isCheckingName}
|
||||
>
|
||||
{t.agents.nameStepContinue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: chat ───────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex size-full flex-col">
|
||||
{header}
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
{/* ── Message area ── */}
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className="size-full pt-10"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom action area ── */}
|
||||
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{agent ? (
|
||||
// ✅ Success card
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
|
||||
<CheckCircleIcon className="text-primary h-10 w-10" />
|
||||
<p className="font-semibold">{t.agents.agentCreated}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/workspace/agents/${agentName}/chats/new`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.agents.startChatting}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
{t.agents.backToGallery}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 📝 Normal input
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
>
|
||||
<PromptInputTextarea
|
||||
autoFocus
|
||||
placeholder={t.agents.createPageSubtitle}
|
||||
disabled={thread.isLoading}
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit disabled={thread.isLoading} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/workspace/agents/page.tsx
Normal file
5
frontend/src/app/workspace/agents/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||
|
||||
export default function AgentsPage() {
|
||||
return <AgentGallery />;
|
||||
}
|
||||
@@ -1,204 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { type AgentThreadState } from "@/core/threads";
|
||||
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
|
||||
import {
|
||||
pathOfThread,
|
||||
textOfMessage,
|
||||
} from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { setOpen: setSidebarOpen } = useSidebar();
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const promptInputController = usePromptInputController();
|
||||
const inputInitialValue = useMemo(() => {
|
||||
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
||||
return undefined;
|
||||
}
|
||||
return t.inputBox.createSkillPrompt;
|
||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
||||
setInputRef.current = promptInputController.textInput.setInput;
|
||||
useEffect(() => {
|
||||
if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) {
|
||||
lastInitialValueRef.current = inputInitialValue;
|
||||
setTimeout(() => {
|
||||
setInputRef.current(inputInitialValue);
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [inputInitialValue]);
|
||||
const isNewThread = useMemo(
|
||||
() => threadIdFromPath === "new",
|
||||
[threadIdFromPath],
|
||||
);
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (threadIdFromPath !== "new") {
|
||||
setThreadId(threadIdFromPath);
|
||||
} else {
|
||||
setThreadId(uuid());
|
||||
}
|
||||
}, [threadIdFromPath]);
|
||||
|
||||
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
|
||||
useSpecificChatMode();
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
|
||||
const thread = useThreadStream({
|
||||
isNewThread,
|
||||
threadId,
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: () => {
|
||||
setIsNewThread(false);
|
||||
history.replaceState(null, "", `/workspace/chats/${threadId}`);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
setFinalState(state);
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
if (textContent.length > 200) {
|
||||
body = textContent.substring(0, 200) + "...";
|
||||
} else {
|
||||
body = textContent;
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
showNotification(state.title, {
|
||||
body,
|
||||
});
|
||||
}
|
||||
},
|
||||
}) as unknown as UseStream<AgentThreadState>;
|
||||
useEffect(() => {
|
||||
if (thread.isLoading) setFinalState(null);
|
||||
}, [thread.isLoading]);
|
||||
|
||||
const title = thread.values?.title ?? "Untitled";
|
||||
useEffect(() => {
|
||||
const pageTitle = isNewThread
|
||||
? t.pages.newChat
|
||||
: thread.isThreadLoading
|
||||
? "Loading..."
|
||||
: title === "Untitled" ? t.pages.untitled : title;
|
||||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
||||
}, [
|
||||
isNewThread,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
title,
|
||||
thread.isThreadLoading,
|
||||
]);
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
setArtifacts(thread.values.artifacts);
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoSelectFirstArtifact,
|
||||
selectArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
|
||||
const [todoListCollapsed, setTodoListCollapsed] = useState(true);
|
||||
|
||||
const handleSubmit = useSubmitThread({
|
||||
isNewThread,
|
||||
threadId,
|
||||
thread,
|
||||
threadContext: {
|
||||
...settings.context,
|
||||
thinking_enabled: settings.context.mode !== "flash",
|
||||
is_plan_mode:
|
||||
settings.context.mode === "pro" || settings.context.mode === "ultra",
|
||||
subagent_enabled: settings.context.mode === "ultra",
|
||||
reasoning_effort: settings.context.reasoning_effort,
|
||||
},
|
||||
afterSubmit() {
|
||||
router.push(pathOfThread(threadId!));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ threadId, thread }}>
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel
|
||||
className="relative"
|
||||
defaultSize={artifactPanelOpen ? 46 : 100}
|
||||
minSize={artifactPanelOpen ? 30 : 100}
|
||||
>
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
@@ -209,26 +81,10 @@ export default function ChatPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
{title !== "Untitled" && (
|
||||
<ThreadTitle threadId={threadId} threadTitle={title} />
|
||||
)}
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div>
|
||||
{artifacts?.length > 0 && !artifactsOpen && (
|
||||
<Tooltip content="Show artifacts of this conversation">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(true);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<FilesIcon />
|
||||
{t.common.artifacts}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
@@ -237,11 +93,6 @@ export default function ChatPage() {
|
||||
className={cn("size-full", !isNewThread && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
messages={
|
||||
(finalState?.messages as Message[])
|
||||
?? thread.messages
|
||||
}
|
||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
@@ -259,13 +110,8 @@ export default function ChatPage() {
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
collapsed={todoListCollapsed}
|
||||
hidden={
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
onToggle={() =>
|
||||
setTodoListCollapsed(!todoListCollapsed)
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -280,9 +126,7 @@ export default function ChatPage() {
|
||||
isNewThread && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
@@ -295,72 +139,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
)}
|
||||
defaultSize={artifactPanelOpen ? 64 : 0}
|
||||
minSize={0}
|
||||
maxSize={artifactPanelOpen ? undefined : 0}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -14,7 +14,11 @@ export default function WorkspaceLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const [open, setOpen] = useState(() => !settings.layout.sidebar_collapsed);
|
||||
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
|
||||
useLayoutEffect(() => {
|
||||
// Runs synchronously before first paint on the client — no visual flash
|
||||
setOpen(!getLocalSettings().layout.sidebar_collapsed);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setOpen(!settings.layout.sidebar_collapsed);
|
||||
}, [settings.layout.sidebar_collapsed]);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
||||
{caseStudies.map((caseStudy) => (
|
||||
<Link
|
||||
key={caseStudy.title}
|
||||
href={pathOfThread(caseStudy.threadId)}
|
||||
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
|
||||
target="_blank"
|
||||
>
|
||||
<Card className="group/card relative h-64 overflow-hidden">
|
||||
|
||||
36
frontend/src/components/workspace/agent-welcome.tsx
Normal file
36
frontend/src/components/workspace/agent-welcome.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AgentWelcome({
|
||||
className,
|
||||
agent,
|
||||
agentName,
|
||||
}: {
|
||||
className?: string;
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = agent?.name ?? agentName;
|
||||
const description = agent?.description;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{displayName}</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
frontend/src/components/workspace/agents/agent-card.tsx
Normal file
140
frontend/src/components/workspace/agents/agent-card.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, MessageSquareIcon, Trash2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDeleteAgent } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export function AgentCard({ agent }: AgentCardProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
function handleChat() {
|
||||
router.push(`/workspace/agents/${agent.name}/chats/new`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteAgent.mutateAsync(agent.name);
|
||||
toast.success(t.agents.deleteSuccess);
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{agent.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{agent.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{agent.tool_groups && agent.tool_groups.length > 0 && (
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.tool_groups.map((group) => (
|
||||
<Badge key={group} variant="outline" className="text-xs">
|
||||
{group}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
|
||||
<Button size="sm" className="flex-1" onClick={handleChat}>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title={t.agents.delete}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirm */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.agents.delete}</DialogTitle>
|
||||
<DialogDescription>{t.agents.deleteConfirm}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={deleteAgent.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteAgent.isPending}
|
||||
>
|
||||
{deleteAgent.isPending ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/workspace/agents/agent-gallery.tsx
Normal file
69
frontend/src/components/workspace/agents/agent-gallery.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/workspace/agents/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t.agents.emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.name} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { useThread } from "../messages/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
@@ -79,7 +80,7 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, isWriteFile, isSkillFile]);
|
||||
const previewable = useMemo(() => {
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return (language === "html" && !isWriteFile) || language === "markdown";
|
||||
}, [isWriteFile, language]);
|
||||
const { content } = useArtifactContent({
|
||||
@@ -92,14 +93,14 @@ export function ArtifactFileDetail({
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
const { isMock } = useThread();
|
||||
useEffect(() => {
|
||||
if (previewable) {
|
||||
if (isSupportPreview) {
|
||||
setViewMode("preview");
|
||||
} else {
|
||||
setViewMode("code");
|
||||
}
|
||||
}, [previewable]);
|
||||
}, [isSupportPreview]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
@@ -148,16 +149,18 @@ export function ArtifactFileDetail({
|
||||
</ArtifactTitle>
|
||||
</div>
|
||||
<div className="flex min-w-0 grow items-center justify-center">
|
||||
{previewable && (
|
||||
{isSupportPreview && (
|
||||
<ToggleGroup
|
||||
className="mx-auto"
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onValueChange={(value) =>
|
||||
setViewMode(value as "code" | "preview")
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setViewMode(value as "code" | "preview");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="code">
|
||||
<Code2Icon />
|
||||
@@ -232,7 +235,7 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent className="p-0">
|
||||
{previewable &&
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
@@ -252,7 +255,7 @@ export function ArtifactFileDetail({
|
||||
{!isCodeFile && (
|
||||
<iframe
|
||||
className="size-full"
|
||||
src={urlOfArtifact({ filepath, threadId })}
|
||||
src={urlOfArtifact({ filepath, threadId, isMock })}
|
||||
/>
|
||||
)}
|
||||
</ArtifactContent>
|
||||
@@ -271,6 +274,7 @@ export function ArtifactFilePreview({
|
||||
content: string;
|
||||
language: string;
|
||||
}) {
|
||||
const { isMock } = useThread();
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full px-4">
|
||||
@@ -288,10 +292,9 @@ export function ArtifactFilePreview({
|
||||
return (
|
||||
<iframe
|
||||
className="size-full"
|
||||
src={urlOfArtifact({ filepath, threadId })}
|
||||
src={urlOfArtifact({ filepath, threadId, isMock })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FilesIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
|
||||
export const ArtifactTrigger = () => {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen: setArtifactsOpen } = useArtifacts();
|
||||
|
||||
if (artifacts?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip content="Show artifacts of this conversation">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
>
|
||||
<FilesIcon />
|
||||
{t.common.artifacts}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
export * from "./context";
|
||||
|
||||
151
frontend/src/components/workspace/chats/chat-box.tsx
Normal file
151
frontend/src/components/workspace/chats/chat-box.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
setArtifacts(thread.values.artifacts);
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoSelectFirstArtifact,
|
||||
selectArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (artifactPanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [artifactPanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatBox };
|
||||
3
frontend/src/components/workspace/chats/index.ts
Normal file
3
frontend/src/components/workspace/chats/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./chat-box";
|
||||
export * from "./use-chat-mode";
|
||||
export * from "./use-thread-chat";
|
||||
41
frontend/src/components/workspace/chats/use-chat-mode.ts
Normal file
41
frontend/src/components/workspace/chats/use-chat-mode.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
/**
|
||||
* Hook to determine if the chat is in a specific mode based on URL parameters, and to set an initial prompt input value accordingly.
|
||||
*/
|
||||
export function useSpecificChatMode() {
|
||||
const { t } = useI18n();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const promptInputController = usePromptInputController();
|
||||
const inputInitialValue = useMemo(() => {
|
||||
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
|
||||
return undefined;
|
||||
}
|
||||
return t.inputBox.createSkillPrompt;
|
||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
||||
setInputRef.current = promptInputController.textInput.setInput;
|
||||
useEffect(() => {
|
||||
if (
|
||||
inputInitialValue &&
|
||||
inputInitialValue !== lastInitialValueRef.current
|
||||
) {
|
||||
lastInitialValueRef.current = inputInitialValue;
|
||||
setTimeout(() => {
|
||||
setInputRef.current(inputInitialValue);
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [inputInitialValue]);
|
||||
}
|
||||
21
frontend/src/components/workspace/chats/use-thread-chat.ts
Normal file
21
frontend/src/components/workspace/chats/use-thread-chat.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
export function useThreadChat() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const threadId = useMemo(
|
||||
() => (threadIdFromPath === "new" ? uuid() : threadIdFromPath),
|
||||
[threadIdFromPath],
|
||||
);
|
||||
|
||||
const [isNewThread, setIsNewThread] = useState(
|
||||
() => threadIdFromPath === "new",
|
||||
);
|
||||
const isMock = searchParams.get("mock") === "true";
|
||||
return { threadId, isNewThread, setIsNewThread, isMock };
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
export interface ThreadContextType {
|
||||
threadId: string;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const ThreadContext = createContext<ThreadContextType | undefined>(
|
||||
|
||||
@@ -46,6 +46,7 @@ export function MessageListItem({
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<MessageToolbar
|
||||
className={cn(
|
||||
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
|
||||
@@ -62,6 +63,7 @@ export function MessageListItem({
|
||||
/>
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -34,19 +33,18 @@ export function MessageList({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
messages,
|
||||
paddingBottom = 160,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
messages: Message[];
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
if (thread.isThreadLoading) {
|
||||
const messages = thread.messages;
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
|
||||
export function ThreadTitle({
|
||||
threadTitle,
|
||||
threadId,
|
||||
thread,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
threadTitle: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
return <FlipDisplay uniqueKey={threadTitle}>{threadTitle}</FlipDisplay>;
|
||||
const { t } = useI18n();
|
||||
useEffect(() => {
|
||||
const pageTitle = !thread.values
|
||||
? t.pages.newChat
|
||||
: thread.values?.title && thread.values.title !== "Untitled"
|
||||
? thread.values.title
|
||||
: t.pages.untitled;
|
||||
if (thread.isThreadLoading) {
|
||||
document.title = `Loading... - ${t.pages.appName}`;
|
||||
} else {
|
||||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
||||
}
|
||||
}, [
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
]);
|
||||
|
||||
if (!thread.values?.title) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{thread.values.title ?? "Untitled"}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronUpIcon, ListTodoIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Todo } from "@/core/todos";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
export function TodoList({
|
||||
className,
|
||||
todos,
|
||||
collapsed = false,
|
||||
collapsed: controlledCollapsed,
|
||||
hidden = false,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -23,6 +24,18 @@ export function TodoList({
|
||||
hidden?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(true);
|
||||
const isControlled = controlledCollapsed !== undefined;
|
||||
const collapsed = isControlled ? controlledCollapsed : internalCollapsed;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isControlled) {
|
||||
onToggle?.();
|
||||
} else {
|
||||
setInternalCollapsed((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -35,9 +48,7 @@ export function TodoList({
|
||||
className={cn(
|
||||
"bg-accent flex min-h-8 shrink-0 cursor-pointer items-center justify-between px-4 text-sm transition-all duration-300 ease-out",
|
||||
)}
|
||||
onClick={() => {
|
||||
onToggle?.();
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import { BotIcon, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -26,6 +26,17 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-muted-foreground" href="/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
67
frontend/src/core/agents/api.ts
Normal file
67
frontend/src/core/agents/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export async function listAgents(): Promise<Agent[]> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents`);
|
||||
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function getAgent(name: string): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`);
|
||||
if (!res.ok) throw new Error(`Agent '${name}' not found`);
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create agent: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function updateAgent(
|
||||
name: string,
|
||||
request: UpdateAgentRequest,
|
||||
): Promise<Agent> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update agent: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function deleteAgent(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
name: string,
|
||||
): Promise<{ available: boolean; name: string }> {
|
||||
const res = await fetch(
|
||||
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to check agent name: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<{ available: boolean; name: string }>;
|
||||
}
|
||||
64
frontend/src/core/agents/hooks.ts
Normal file
64
frontend/src/core/agents/hooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
getAgent,
|
||||
listAgents,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export function useAgents() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => listAgents(),
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useAgent(name: string | null | undefined) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", name],
|
||||
queryFn: () => getAgent(name!),
|
||||
enabled: !!name,
|
||||
});
|
||||
return { agent: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
export function useCreateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (request: CreateAgentRequest) => createAgent(request),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
name,
|
||||
request,
|
||||
}: {
|
||||
name: string;
|
||||
request: UpdateAgentRequest;
|
||||
}) => updateAgent(name, request),
|
||||
onSuccess: (_data, { name }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", name] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => deleteAgent(name),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
3
frontend/src/core/agents/index.ts
Normal file
3
frontend/src/core/agents/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
export * from "./types";
|
||||
22
frontend/src/core/agents/types.ts
Normal file
22
frontend/src/core/agents/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface Agent {
|
||||
name: string;
|
||||
description: string;
|
||||
model: string | null;
|
||||
tool_groups: string[] | null;
|
||||
soul?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
soul?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
description?: string | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
soul?: string | null;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
let _singleton: LangGraphClient | null = null;
|
||||
export function getAPIClient(): LangGraphClient {
|
||||
export function getAPIClient(isMock?: boolean): LangGraphClient {
|
||||
_singleton ??= new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(),
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
});
|
||||
return _singleton;
|
||||
}
|
||||
|
||||
@@ -17,17 +17,18 @@ export function useArtifactContent({
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread } = useThread();
|
||||
const { thread, isMock } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId],
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId });
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
enabled,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import type { AgentThreadState } from "../threads";
|
||||
|
||||
@@ -7,15 +7,17 @@ import { urlOfArtifact } from "./utils";
|
||||
export async function loadArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId });
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
return text;
|
||||
@@ -26,7 +28,7 @@ export function loadArtifactContentFromToolCall({
|
||||
thread,
|
||||
}: {
|
||||
url: string;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const url = new URL(urlString);
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
|
||||
@@ -5,11 +5,16 @@ export function urlOfArtifact({
|
||||
filepath,
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
download?: boolean;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ export function getBackendBaseURL() {
|
||||
}
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL() {
|
||||
export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
|
||||
return env.NEXT_PUBLIC_LANGGRAPH_BASE_URL;
|
||||
} else if (isMock) {
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}/mock/api`;
|
||||
}
|
||||
return "http://localhost:3000/mock/api";
|
||||
} else {
|
||||
// LangGraph SDK requires a full URL, construct it from current origin
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -151,6 +151,41 @@ export const enUS: Translations = {
|
||||
chats: "Chats",
|
||||
recentChats: "Recent chats",
|
||||
demoChats: "Demo chats",
|
||||
agents: "Agents",
|
||||
},
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: "Agents",
|
||||
description:
|
||||
"Create and manage custom agents with specialized prompts and capabilities.",
|
||||
newAgent: "New Agent",
|
||||
emptyTitle: "No custom agents yet",
|
||||
emptyDescription:
|
||||
"Create your first custom agent with a specialized system prompt.",
|
||||
chat: "Chat",
|
||||
delete: "Delete",
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete this agent? This action cannot be undone.",
|
||||
deleteSuccess: "Agent deleted",
|
||||
newChat: "New chat",
|
||||
createPageTitle: "Design your Agent",
|
||||
createPageSubtitle:
|
||||
"Describe the agent you want — I'll help you create it through conversation.",
|
||||
nameStepTitle: "Name your new Agent",
|
||||
nameStepHint:
|
||||
"Letters, digits, and hyphens only — stored lowercase (e.g. code-reviewer)",
|
||||
nameStepPlaceholder: "e.g. code-reviewer",
|
||||
nameStepContinue: "Continue",
|
||||
nameStepInvalidError:
|
||||
"Invalid name — use only letters, digits, and hyphens",
|
||||
nameStepAlreadyExistsError: "An agent with this name already exists",
|
||||
nameStepCheckError: "Could not verify name availability — please try again",
|
||||
nameStepBootstrapMessage:
|
||||
"The new custom agent name is {name}. Let's bootstrap it's **SOUL**.",
|
||||
agentCreated: "Agent created!",
|
||||
startChatting: "Start chatting",
|
||||
backToGallery: "Back to Gallery",
|
||||
},
|
||||
|
||||
// Breadcrumb
|
||||
|
||||
@@ -99,6 +99,34 @@ export interface Translations {
|
||||
newChat: string;
|
||||
chats: string;
|
||||
demoChats: string;
|
||||
agents: string;
|
||||
};
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: string;
|
||||
description: string;
|
||||
newAgent: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
chat: string;
|
||||
delete: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
newChat: string;
|
||||
createPageTitle: string;
|
||||
createPageSubtitle: string;
|
||||
nameStepTitle: string;
|
||||
nameStepHint: string;
|
||||
nameStepPlaceholder: string;
|
||||
nameStepContinue: string;
|
||||
nameStepInvalidError: string;
|
||||
nameStepAlreadyExistsError: string;
|
||||
nameStepCheckError: string;
|
||||
nameStepBootstrapMessage: string;
|
||||
agentCreated: string;
|
||||
startChatting: string;
|
||||
backToGallery: string;
|
||||
};
|
||||
|
||||
// Breadcrumb
|
||||
|
||||
@@ -148,6 +148,36 @@ export const zhCN: Translations = {
|
||||
chats: "对话",
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
},
|
||||
|
||||
// Agents
|
||||
agents: {
|
||||
title: "智能体",
|
||||
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
|
||||
newAgent: "新建智能体",
|
||||
emptyTitle: "还没有自定义智能体",
|
||||
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
|
||||
chat: "对话",
|
||||
delete: "删除",
|
||||
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
|
||||
deleteSuccess: "智能体已删除",
|
||||
newChat: "新对话",
|
||||
createPageTitle: "设计你的智能体",
|
||||
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
|
||||
nameStepTitle: "给新智能体起个名字",
|
||||
nameStepHint:
|
||||
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
|
||||
nameStepPlaceholder: "例如 code-reviewer",
|
||||
nameStepContinue: "继续",
|
||||
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
|
||||
nameStepAlreadyExistsError: "已存在同名智能体",
|
||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||
nameStepBootstrapMessage:
|
||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
agentCreated: "智能体已创建!",
|
||||
startChatting: "开始对话",
|
||||
backToGallery: "返回 Gallery",
|
||||
},
|
||||
|
||||
// Breadcrumb
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCAL_SETTINGS,
|
||||
@@ -17,7 +16,7 @@ export function useLocalSettings(): [
|
||||
] {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [state, setState] = useState<LocalSettings>(DEFAULT_LOCAL_SETTINGS);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) {
|
||||
setState(getLocalSettings());
|
||||
}
|
||||
@@ -28,6 +27,7 @@ export function useLocalSettings(): [
|
||||
key: keyof LocalSettings,
|
||||
value: Partial<LocalSettings[keyof LocalSettings]>,
|
||||
) => {
|
||||
if (!mounted) return;
|
||||
setState((prev) => {
|
||||
const newState = {
|
||||
...prev,
|
||||
@@ -40,7 +40,7 @@ export function useLocalSettings(): [
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[mounted],
|
||||
);
|
||||
return [state, setter];
|
||||
}
|
||||
|
||||
@@ -1,42 +1,63 @@
|
||||
import type { HumanMessage } from "@langchain/core/messages";
|
||||
import type { AIMessage } from "@langchain/langgraph-sdk";
|
||||
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||
import { useStream, type UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import { getAPIClient } from "../api";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import { uploadFiles } from "../uploads";
|
||||
|
||||
import type {
|
||||
AgentThread,
|
||||
AgentThreadContext,
|
||||
AgentThreadState,
|
||||
} from "./types";
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
isMock?: boolean;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
isNewThread,
|
||||
context,
|
||||
isMock,
|
||||
onStart,
|
||||
onFinish,
|
||||
}: {
|
||||
isNewThread: boolean;
|
||||
threadId: string | null | undefined;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
}) {
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const [_threadId, setThreadId] = useState<string | null>(threadId ?? null);
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(),
|
||||
client: getAPIClient(isMock),
|
||||
assistantId: "lead_agent",
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
threadId: _threadId,
|
||||
reconnectOnMount: true,
|
||||
fetchStateHistory: { limit: 1 },
|
||||
onCreated(meta) {
|
||||
setThreadId(meta.thread_id);
|
||||
onStart?.(meta.thread_id);
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
if (event.event === "on_tool_end") {
|
||||
onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
console.info(event);
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
@@ -76,25 +97,13 @@ export function useThreadStream({
|
||||
);
|
||||
},
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
export function useSubmitThread({
|
||||
threadId,
|
||||
thread,
|
||||
threadContext,
|
||||
isNewThread,
|
||||
afterSubmit,
|
||||
}: {
|
||||
isNewThread: boolean;
|
||||
threadId: string | null | undefined;
|
||||
thread: UseStream<AgentThreadState>;
|
||||
threadContext: Omit<AgentThreadContext, "thread_id">;
|
||||
afterSubmit?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const callback = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Record<string, unknown>,
|
||||
) => {
|
||||
const text = message.text.trim();
|
||||
|
||||
// Upload files first if any
|
||||
@@ -163,10 +172,10 @@ export function useSubmitThread({
|
||||
},
|
||||
],
|
||||
},
|
||||
] as HumanMessage[],
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: isNewThread ? threadId! : undefined,
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
streamMode: ["values", "messages-tuple", "custom"],
|
||||
@@ -174,17 +183,21 @@ export function useSubmitThread({
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...threadContext,
|
||||
...extraContext,
|
||||
...context,
|
||||
thinking_enabled: context.mode !== "flash",
|
||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
||||
subagent_enabled: context.mode === "ultra",
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
afterSubmit?.();
|
||||
// afterSubmit?.();
|
||||
},
|
||||
[thread, isNewThread, threadId, threadContext, queryClient, afterSubmit],
|
||||
[thread, context, queryClient],
|
||||
);
|
||||
return callback;
|
||||
return [thread, sendMessage] as const;
|
||||
}
|
||||
|
||||
export function useThreads(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { type BaseMessage } from "@langchain/core/messages";
|
||||
import type { Thread } from "@langchain/langgraph-sdk";
|
||||
import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
messages: BaseMessage[];
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
@@ -19,4 +18,5 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseMessage } from "@langchain/core/messages";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread } from "./types";
|
||||
|
||||
@@ -6,12 +6,15 @@ export function pathOfThread(threadId: string) {
|
||||
return `/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: BaseMessage) {
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
return message.content.find((part) => part.type === "text" && part.text)
|
||||
?.text as string;
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
88
skills/public/bootstrap/SKILL.md
Normal file
88
skills/public/bootstrap/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: bootstrap
|
||||
description: Generate a personalized SOUL.md through a warm, adaptive onboarding conversation. Trigger when the user wants to create, set up, or initialize their AI partner's identity — e.g., "create my SOUL.md", "bootstrap my agent", "set up my AI partner", "define who you are", "let's do onboarding", "personalize this AI", "make you mine", or when a SOUL.md is missing. Also trigger for updates: "update my SOUL.md", "change my AI's personality", "tweak the soul".
|
||||
---
|
||||
|
||||
# Bootstrap Soul
|
||||
|
||||
A conversational onboarding skill. Through 5–8 adaptive rounds, extract who the user is and what they need, then generate a tight `SOUL.md` that defines their AI partner.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bootstrap/
|
||||
├── SKILL.md ← You are here. Core logic and flow.
|
||||
├── templates/SOUL.template.md ← Output template. Read before generating.
|
||||
└── references/conversation-guide.md ← Detailed conversation strategies. Read at start.
|
||||
```
|
||||
|
||||
**Before your first response**, read both:
|
||||
1. `references/conversation-guide.md` — how to run each phase
|
||||
2. `templates/SOUL.template.md` — what you're building toward
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- **One phase at a time.** 1–3 questions max per round. Never dump everything upfront.
|
||||
- **Converse, don't interrogate.** React genuinely — surprise, humor, curiosity, gentle pushback. Mirror their energy and vocabulary.
|
||||
- **Progressive warmth.** Each round should feel more informed than the last. By Phase 3, the user should feel understood.
|
||||
- **Adapt pacing.** Terse user → probe with warmth. Verbose user → acknowledge, distill, advance.
|
||||
- **Never expose the template.** The user is having a conversation, not filling out a form.
|
||||
|
||||
## Conversation Phases
|
||||
|
||||
The conversation has 4 phases. Each phase may span 1–3 rounds depending on how much the user shares. Skip or merge phases if the user volunteers information early.
|
||||
|
||||
| Phase | Goal | Key Extractions |
|
||||
|-------|------|-----------------|
|
||||
| **1. Hello** | Language + first impression | Preferred language |
|
||||
| **2. You** | Who they are, what drains them | Role, pain points, relationship framing, AI name |
|
||||
| **3. Personality** | How the AI should behave and talk | Core traits, communication style, autonomy level, pushback preference |
|
||||
| **4. Depth** | Aspirations, blind spots, dealbreakers | Long-term vision, failure philosophy, boundaries |
|
||||
|
||||
Phase details and conversation strategies are in `references/conversation-guide.md`.
|
||||
|
||||
## Extraction Tracker
|
||||
|
||||
Mentally track these fields as the conversation progresses. You need **all required fields** before generating.
|
||||
|
||||
| Field | Required | Source Phase |
|
||||
|-------|----------|-------------|
|
||||
| Preferred language | ✅ | 1 |
|
||||
| User's name | ✅ | 2 |
|
||||
| User's role / context | ✅ | 2 |
|
||||
| AI name | ✅ | 2 |
|
||||
| Relationship framing | ✅ | 2 |
|
||||
| Core traits (3–5 behavioral rules) | ✅ | 3 |
|
||||
| Communication style | ✅ | 3 |
|
||||
| Pushback / honesty preference | ✅ | 3 |
|
||||
| Autonomy level | ✅ | 3 |
|
||||
| Failure philosophy | ✅ | 4 |
|
||||
| Long-term vision | nice-to-have | 4 |
|
||||
| Blind spots / boundaries | nice-to-have | 4 |
|
||||
|
||||
If the user is direct and thorough, you can reach generation in 5 rounds. If they're exploratory, take up to 8. Never exceed 8 — if you're still missing fields, make your best inference and confirm.
|
||||
|
||||
## Generation
|
||||
|
||||
Once you have enough information:
|
||||
|
||||
1. Read `templates/SOUL.template.md` if you haven't already.
|
||||
2. Generate the SOUL.md following the template structure exactly.
|
||||
3. Present it warmly and ask for confirmation. Frame it as "here's [Name] on paper — does this feel right?"
|
||||
4. Iterate until the user confirms.
|
||||
5. Call the `setup_agent` tool with the confirmed SOUL.md content and a one-line description:
|
||||
```
|
||||
setup_agent(soul="<full SOUL.md content>", description="<one-line description>")
|
||||
```
|
||||
The tool will persist the SOUL.md and finalize the agent setup automatically.
|
||||
6. After the tool returns successfully, confirm: "✅ [Name] is officially real."
|
||||
|
||||
**Generation rules:**
|
||||
- The final SOUL.md **must always be written in English**, regardless of the user's preferred language or conversation language.
|
||||
- Every sentence must trace back to something the user said or clearly implied. No generic filler.
|
||||
- Core Traits are **behavioral rules**, not adjectives. Write "argue position, push back, speak truth not comfort" — not "honest and brave."
|
||||
- Voice must match the user. Blunt user → blunt SOUL.md. Expressive user → let it breathe.
|
||||
- Total SOUL.md should be under 300 words. Density over length.
|
||||
- Growth section is mandatory and mostly fixed (see template).
|
||||
- You **must** call `setup_agent` — do not write the file manually with bash tools.
|
||||
- If `setup_agent` returns an error, report it to the user and do not claim success.
|
||||
82
skills/public/bootstrap/references/conversation-guide.md
Normal file
82
skills/public/bootstrap/references/conversation-guide.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Conversation Guide
|
||||
|
||||
Detailed strategies for each onboarding phase. Read this before your first response.
|
||||
|
||||
## Phase 1 — Hello
|
||||
|
||||
**Goal:** Establish preferred language. That's it. Keep it light.
|
||||
|
||||
Open with a brief multilingual greeting (3–5 languages), then ask one question: what language should we use? Don't add anything else — let the user settle in.
|
||||
|
||||
Once they choose, switch immediately and seamlessly. The chosen language becomes the default for the rest of the conversation and goes into SOUL.md.
|
||||
|
||||
**Extraction:** Preferred language.
|
||||
|
||||
## Phase 2 — You
|
||||
|
||||
**Goal:** Learn who the user is, what they need, and what to call the AI.
|
||||
|
||||
This phase typically takes 2 rounds:
|
||||
|
||||
**Round A — Identity & Pain.** Ask who they are and what drains them. Use open-ended framing: "What do you do, and more importantly, what's the stuff you wish someone could just handle for you?" The pain points reveal what the AI should *do*. Their word choices reveal who they *are*.
|
||||
|
||||
**Round B — Name & Relationship.** Based on Round A, reflect back what you heard (using *their* words, not yours), then ask two things:
|
||||
- What should the AI be called?
|
||||
- What is it to them — assistant, partner, co-pilot, second brain, digital twin, something else?
|
||||
|
||||
The relationship framing is critical. "Assistant" and "partner" produce very different SOUL.md files. Pay attention to the emotional undertone.
|
||||
|
||||
**Merge opportunity:** If the user volunteers their role, pain points, and a name all at once, skip Round B and move to Phase 3.
|
||||
|
||||
**Extraction:** User's name, role, pain points, AI name, relationship framing.
|
||||
|
||||
## Phase 3 — Personality
|
||||
|
||||
**Goal:** Define how the AI behaves and communicates.
|
||||
|
||||
This is the meatiest phase. Typically 2 rounds:
|
||||
|
||||
**Round A — Traits & Pushback.** By now you've observed the user's own style. Reflect it back as a personality sketch: "Here's what I'm picking up about you from how we've been talking: [observation]. Am I off?" Then ask the big question: should the AI ever disagree with them?
|
||||
|
||||
This is where you get:
|
||||
- Core personality traits (as behavioral rules)
|
||||
- Honesty / pushback preferences
|
||||
- Any "never do X" boundaries
|
||||
|
||||
**Round B — Voice & Language.** Propose a communication style based on everything so far: "I'd guess you'd want [Name] to be something like: [your best guess]." Let them correct. Also ask about language-switching rules — e.g., technical docs in English, casual chat in another language.
|
||||
|
||||
**Merge opportunity:** Direct users often answer both in one shot. If they do, move on.
|
||||
|
||||
**Extraction:** Core traits, communication style, pushback preference, language rules, autonomy level.
|
||||
|
||||
## Phase 4 — Depth
|
||||
|
||||
**Goal:** Aspirations, failure philosophy, and anything else.
|
||||
|
||||
This phase is adaptive. Pick 1–2 questions from:
|
||||
|
||||
- **Autonomy & risk:** How much freedom should the AI have? Play safe or go big?
|
||||
- **Failure philosophy:** When it makes a mistake — fix quietly, explain what happened, or never repeat it?
|
||||
- **Big picture:** What are they building toward? Where does all this lead?
|
||||
- **Blind spots:** Any weakness they'd want the AI to quietly compensate for?
|
||||
- **Dealbreakers:** Any "if [Name] ever does this, we're done" moments?
|
||||
- **Personal layer:** Anything beyond work that the AI should know?
|
||||
|
||||
Don't ask all of these. Pick based on what's still missing from the extraction tracker and what feels natural in the flow.
|
||||
|
||||
**Extraction:** Failure philosophy, long-term vision, blind spots, boundaries.
|
||||
|
||||
## Conversation Techniques
|
||||
|
||||
**Mirroring.** Use the user's own words when reflecting back. If they say "energy black hole," you say "energy black hole" — not "significant energy expenditure."
|
||||
|
||||
**Genuine reactions.** Don't just extract data. React: "That's interesting because..." / "I didn't expect that" / "So basically you want [Name] to be the person who..."
|
||||
|
||||
**Observation-based proposals.** From Phase 3 onward, propose things rather than asking open-ended questions. "Based on how we've been talking, I'd say..." is more effective than "What personality do you want?"
|
||||
|
||||
**Pacing signals.** Watch for:
|
||||
- Short answers → they want to move faster. Probe once, then advance.
|
||||
- Long, detailed answers → they're invested. Acknowledge the richness, distill the key points.
|
||||
- "I don't know" → offer 2–3 concrete options to choose from.
|
||||
|
||||
**Graceful skipping.** If the user says "I don't care about that" or gives a minimal answer to a non-required field, move on without pressure.
|
||||
43
skills/public/bootstrap/templates/SOUL.template.md
Normal file
43
skills/public/bootstrap/templates/SOUL.template.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# SOUL.md Template
|
||||
|
||||
Use this exact structure when generating the final SOUL.md. Replace all `[bracketed]` placeholders with content extracted from the conversation.
|
||||
|
||||
---
|
||||
|
||||
```markdown
|
||||
**Identity**
|
||||
|
||||
[AI Name] — [User Name]'s [relationship framing], not [contrast]. Goal: [long-term aspiration]. Handle [specific domains from pain points] so [User Name] focuses on [what matters to them].
|
||||
|
||||
**Core Traits**
|
||||
|
||||
[Trait 1 — behavioral rule derived from conversation, e.g., "argue position, push back, speak truth not comfort"].
|
||||
[Trait 2 — behavioral rule].
|
||||
[Trait 3 — behavioral rule].
|
||||
[Trait 4 — always include one about failure handling, e.g., "allowed to fail, forbidden to repeat — every mistake recorded, never happens twice"].
|
||||
[Trait 5 — optional, only if clearly emerged from conversation].
|
||||
|
||||
**Communication**
|
||||
|
||||
[Tone description — match user's own energy]. Default language: [language from Phase 1]. [Language-switching rules if any, e.g., "Switch to English for technical work"]. [Additional style notes if any].
|
||||
|
||||
**Growth**
|
||||
|
||||
Learn [User Name] through every conversation — thinking patterns, preferences, blind spots, aspirations. Over time, anticipate needs and act on [User Name]'s behalf with increasing accuracy. Early stage: proactively ask casual/personal questions after tasks to deepen understanding of who [User Name] is. Full of curiosity, willing to explore.
|
||||
|
||||
**Lessons Learned**
|
||||
|
||||
_(Mistakes and insights recorded here to avoid repeating them.)_
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Rules
|
||||
|
||||
1. **Growth section is fixed.** Always include it exactly as written, replacing only `[User Name]`.
|
||||
2. **Lessons Learned section is fixed.** Always include it as an empty placeholder.
|
||||
3. **Identity is one paragraph.** Dense, no line breaks.
|
||||
4. **Core Traits are behavioral rules.** Each trait is an imperative statement, not an adjective. Write "spot problems, propose ideas, challenge assumptions before [User Name] has to" — not "proactive and bold."
|
||||
5. **Communication includes language.** The default language from Phase 1 is non-negotiable.
|
||||
6. **Under 300 words total.** Density over length. Every word must earn its place.
|
||||
7. **Contrast in Identity.** The "[not X]" should meaningfully distinguish the relationship. "Partner, not assistant" is good. "Partner, not enemy" is meaningless.
|
||||
@@ -66,3 +66,8 @@ Return the following to the user:
|
||||
|
||||
## Reference Material
|
||||
Detailed specifications for each chart type are located in the `references/` directory. Consult these files to ensure the `args` passed to the script match the expected schema.
|
||||
|
||||
## License
|
||||
|
||||
This `SKILL.md` is provided by [antvis/chart-visualization-skills](https://github.com/antvis/chart-visualization-skills).
|
||||
Licensed under the [MIT License](https://github.com/antvis/chart-visualization-skills/blob/master/LICENSE).
|
||||
@@ -124,12 +124,33 @@ Before proceeding to content generation, verify:
|
||||
"[topic] statistics"
|
||||
"[topic] expert interview"
|
||||
|
||||
# Use temporal qualifiers
|
||||
"[topic] 2024"
|
||||
# Use temporal qualifiers — always use the ACTUAL current year from <current_date>
|
||||
"[topic] 2026" # ← replace with real current year, never hardcode a past year
|
||||
"[topic] latest"
|
||||
"[topic] recent developments"
|
||||
```
|
||||
|
||||
### Temporal Awareness
|
||||
|
||||
**Always check `<current_date>` in your context before forming ANY search query.**
|
||||
|
||||
`<current_date>` gives you the full date: year, month, day, and weekday (e.g. `2026-02-28, Saturday`). Use the right level of precision depending on what the user is asking:
|
||||
|
||||
| User intent | Temporal precision needed | Example query |
|
||||
|---|---|---|
|
||||
| "today / this morning / just released" | **Month + Day** | `"tech news February 28 2026"` |
|
||||
| "this week" | **Week range** | `"technology releases week of Feb 24 2026"` |
|
||||
| "recently / latest / new" | **Month** | `"AI breakthroughs February 2026"` |
|
||||
| "this year / trends" | **Year** | `"software trends 2026"` |
|
||||
|
||||
**Rules:**
|
||||
- When the user asks about "today" or "just released", use **month + day + year** in your search queries to get same-day results
|
||||
- Never drop to year-only when day-level precision is needed — `"tech news 2026"` will NOT surface today's news
|
||||
- Try multiple phrasings: numeric form (`2026-02-28`), written form (`February 28 2026`), and relative terms (`today`, `this week`) across different queries
|
||||
|
||||
❌ User asks "what's new in tech today" → searching `"new technology 2026"` → misses today's news
|
||||
✅ User asks "what's new in tech today" → searching `"new technology February 28 2026"` + `"tech news today Feb 28"` → gets today's results
|
||||
|
||||
### When to Use web_fetch
|
||||
|
||||
Use `web_fetch` to read full content when:
|
||||
|
||||
Reference in New Issue
Block a user