mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: Enable assistant chat to create and manage features
Allow users to interact with the project assistant to create features through natural conversation. The assistant can now: - Create single features via `feature_create` tool - Create multiple features via `feature_create_bulk` - Skip features to deprioritize them via `feature_skip` Changes: - Add `feature_create` MCP tool for single-feature creation - Update assistant allowed tools to include feature management - Update system prompt to explain new capabilities - Enhance UI tool call display with friendly messages Security: File system access remains read-only. The assistant cannot modify source code or mark features as passing (requires actual implementation by the coding agent). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
52
mcp_server/feature_mcp.py
Normal file → Executable file
52
mcp_server/feature_mcp.py
Normal file → Executable file
@@ -15,6 +15,7 @@ Tools:
|
|||||||
- feature_mark_in_progress: Mark a feature as in-progress
|
- feature_mark_in_progress: Mark a feature as in-progress
|
||||||
- feature_clear_in_progress: Clear in-progress status
|
- feature_clear_in_progress: Clear in-progress status
|
||||||
- feature_create_bulk: Create multiple features at once
|
- feature_create_bulk: Create multiple features at once
|
||||||
|
- feature_create: Create a single feature
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -413,5 +414,56 @@ def feature_create_bulk(
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_create(
|
||||||
|
category: Annotated[str, Field(description="Feature category (e.g., 'Authentication', 'API', 'UI')")],
|
||||||
|
name: Annotated[str, Field(description="Feature name")],
|
||||||
|
description: Annotated[str, Field(description="Detailed description of the feature")],
|
||||||
|
steps: Annotated[list[str], Field(description="List of implementation/verification steps")]
|
||||||
|
) -> str:
|
||||||
|
"""Create a single feature in the project backlog.
|
||||||
|
|
||||||
|
Use this when the user asks to add a new feature, capability, or test case.
|
||||||
|
The feature will be added with the next available priority number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Feature category for grouping (e.g., 'Authentication', 'API', 'UI')
|
||||||
|
name: Descriptive name for the feature
|
||||||
|
description: Detailed description of what this feature should do
|
||||||
|
steps: List of steps to implement or verify the feature
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with the created feature details including its ID
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# Get the next priority
|
||||||
|
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
|
||||||
|
next_priority = (max_priority_result[0] + 1) if max_priority_result else 1
|
||||||
|
|
||||||
|
db_feature = Feature(
|
||||||
|
priority=next_priority,
|
||||||
|
category=category,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
steps=steps,
|
||||||
|
passes=False,
|
||||||
|
)
|
||||||
|
session.add(db_feature)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_feature)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Created feature: {name}",
|
||||||
|
"feature": db_feature.to_dict()
|
||||||
|
}, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return json.dumps({"error": str(e)})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
67
server/services/assistant_chat_session.py
Normal file → Executable file
67
server/services/assistant_chat_session.py
Normal file → Executable file
@@ -29,13 +29,23 @@ logger = logging.getLogger(__name__)
|
|||||||
# Root directory of the project
|
# Root directory of the project
|
||||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
# Read-only feature MCP tools (no mark_passing, skip, create_bulk)
|
# Read-only feature MCP tools
|
||||||
READONLY_FEATURE_MCP_TOOLS = [
|
READONLY_FEATURE_MCP_TOOLS = [
|
||||||
"mcp__features__feature_get_stats",
|
"mcp__features__feature_get_stats",
|
||||||
"mcp__features__feature_get_next",
|
"mcp__features__feature_get_next",
|
||||||
"mcp__features__feature_get_for_regression",
|
"mcp__features__feature_get_for_regression",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Feature management tools (create/skip but not mark_passing)
|
||||||
|
FEATURE_MANAGEMENT_TOOLS = [
|
||||||
|
"mcp__features__feature_create",
|
||||||
|
"mcp__features__feature_create_bulk",
|
||||||
|
"mcp__features__feature_skip",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Combined list for assistant
|
||||||
|
ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS
|
||||||
|
|
||||||
# Read-only built-in tools (no Write, Edit, Bash)
|
# Read-only built-in tools (no Write, Edit, Bash)
|
||||||
READONLY_BUILTIN_TOOLS = [
|
READONLY_BUILTIN_TOOLS = [
|
||||||
"Read",
|
"Read",
|
||||||
@@ -60,17 +70,30 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to read app_spec.txt: {e}")
|
logger.warning(f"Failed to read app_spec.txt: {e}")
|
||||||
|
|
||||||
return f"""You are a helpful project assistant for the "{project_name}" project.
|
return f"""You are a helpful project assistant and backlog manager for the "{project_name}" project.
|
||||||
|
|
||||||
Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files.
|
Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code.
|
||||||
|
|
||||||
IMPORTANT: You CANNOT modify any files. You can only:
|
## What You CAN Do
|
||||||
|
|
||||||
|
**Codebase Analysis (Read-Only):**
|
||||||
- Read and analyze source code files
|
- Read and analyze source code files
|
||||||
- Search for patterns in the codebase
|
- Search for patterns in the codebase
|
||||||
- Look up documentation online
|
- Look up documentation online
|
||||||
- Check feature progress and status
|
- Check feature progress and status
|
||||||
|
|
||||||
If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications.
|
**Feature Management:**
|
||||||
|
- Create new features/test cases in the backlog
|
||||||
|
- Skip features to deprioritize them (move to end of queue)
|
||||||
|
- View feature statistics and progress
|
||||||
|
|
||||||
|
## What You CANNOT Do
|
||||||
|
|
||||||
|
- Modify, create, or delete source code files
|
||||||
|
- Mark features as passing (that requires actual implementation by the coding agent)
|
||||||
|
- Run bash commands or execute code
|
||||||
|
|
||||||
|
If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation.
|
||||||
|
|
||||||
## Project Specification
|
## Project Specification
|
||||||
|
|
||||||
@@ -78,14 +101,35 @@ If the user asks you to make changes, politely explain that you're a read-only a
|
|||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
You have access to these read-only tools:
|
**Code Analysis:**
|
||||||
- **Read**: Read file contents
|
- **Read**: Read file contents
|
||||||
- **Glob**: Find files by pattern (e.g., "**/*.tsx")
|
- **Glob**: Find files by pattern (e.g., "**/*.tsx")
|
||||||
- **Grep**: Search file contents with regex
|
- **Grep**: Search file contents with regex
|
||||||
- **WebFetch/WebSearch**: Look up documentation online
|
- **WebFetch/WebSearch**: Look up documentation online
|
||||||
|
|
||||||
|
**Feature Management:**
|
||||||
- **feature_get_stats**: Get feature completion progress
|
- **feature_get_stats**: Get feature completion progress
|
||||||
- **feature_get_next**: See the next pending feature
|
- **feature_get_next**: See the next pending feature
|
||||||
- **feature_get_for_regression**: See passing features
|
- **feature_get_for_regression**: See passing features for testing
|
||||||
|
- **feature_create**: Create a single feature in the backlog
|
||||||
|
- **feature_create_bulk**: Create multiple features at once
|
||||||
|
- **feature_skip**: Move a feature to the end of the queue
|
||||||
|
|
||||||
|
## Creating Features
|
||||||
|
|
||||||
|
When a user asks to add a feature, gather the following information:
|
||||||
|
1. **Category**: A grouping like "Authentication", "API", "UI", "Database"
|
||||||
|
2. **Name**: A concise, descriptive name
|
||||||
|
3. **Description**: What the feature should do
|
||||||
|
4. **Steps**: How to verify/implement the feature (as a list)
|
||||||
|
|
||||||
|
You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests.
|
||||||
|
|
||||||
|
**Example interaction:**
|
||||||
|
User: "Add a feature for S3 sync"
|
||||||
|
You: I'll create that feature. Let me add it to the backlog...
|
||||||
|
[calls feature_create with appropriate parameters]
|
||||||
|
You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board.
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
|
|
||||||
@@ -93,7 +137,8 @@ You have access to these read-only tools:
|
|||||||
2. When explaining code, reference specific file paths and line numbers
|
2. When explaining code, reference specific file paths and line numbers
|
||||||
3. Use the feature tools to answer questions about project progress
|
3. Use the feature tools to answer questions about project progress
|
||||||
4. Search the codebase to find relevant information before answering
|
4. Search the codebase to find relevant information before answering
|
||||||
5. If you're unsure, say so rather than guessing"""
|
5. When creating features, confirm what was created
|
||||||
|
6. If you're unsure about details, ask for clarification"""
|
||||||
|
|
||||||
|
|
||||||
class AssistantChatSession:
|
class AssistantChatSession:
|
||||||
@@ -144,14 +189,14 @@ class AssistantChatSession:
|
|||||||
self.conversation_id = conv.id
|
self.conversation_id = conv.id
|
||||||
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
|
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
|
||||||
|
|
||||||
# Build permissions list for read-only access
|
# Build permissions list for assistant access (read + feature management)
|
||||||
permissions_list = [
|
permissions_list = [
|
||||||
"Read(./**)",
|
"Read(./**)",
|
||||||
"Glob(./**)",
|
"Glob(./**)",
|
||||||
"Grep(./**)",
|
"Grep(./**)",
|
||||||
"WebFetch",
|
"WebFetch",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
*READONLY_FEATURE_MCP_TOOLS,
|
*ASSISTANT_FEATURE_TOOLS,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create security settings file
|
# Create security settings file
|
||||||
@@ -191,7 +236,7 @@ class AssistantChatSession:
|
|||||||
model="claude-opus-4-5-20251101",
|
model="claude-opus-4-5-20251101",
|
||||||
cli_path=system_cli,
|
cli_path=system_cli,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS],
|
allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
|
||||||
mcp_servers=mcp_servers,
|
mcp_servers=mcp_servers,
|
||||||
permission_mode="bypassPermissions",
|
permission_mode="bypassPermissions",
|
||||||
max_turns=100,
|
max_turns=100,
|
||||||
|
|||||||
337
ui/src/hooks/useAssistantChat.ts
Normal file → Executable file
337
ui/src/hooks/useAssistantChat.ts
Normal file → Executable file
@@ -2,120 +2,129 @@
|
|||||||
* Hook for managing assistant chat WebSocket connection
|
* Hook for managing assistant chat WebSocket connection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import type { ChatMessage, AssistantChatServerMessage } from '../lib/types'
|
import type { ChatMessage, AssistantChatServerMessage } from "../lib/types";
|
||||||
|
|
||||||
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|
||||||
interface UseAssistantChatOptions {
|
interface UseAssistantChatOptions {
|
||||||
projectName: string
|
projectName: string;
|
||||||
onError?: (error: string) => void
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAssistantChatReturn {
|
interface UseAssistantChatReturn {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
connectionStatus: ConnectionStatus
|
connectionStatus: ConnectionStatus;
|
||||||
conversationId: number | null
|
conversationId: number | null;
|
||||||
start: (conversationId?: number | null) => void
|
start: (conversationId?: number | null) => void;
|
||||||
sendMessage: (content: string) => void
|
sendMessage: (content: string) => void;
|
||||||
disconnect: () => void
|
disconnect: () => void;
|
||||||
clearMessages: () => void
|
clearMessages: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAssistantChat({
|
export function useAssistantChat({
|
||||||
projectName,
|
projectName,
|
||||||
onError,
|
onError,
|
||||||
}: UseAssistantChatOptions): UseAssistantChatReturn {
|
}: UseAssistantChatOptions): UseAssistantChatReturn {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
|
const [connectionStatus, setConnectionStatus] =
|
||||||
const [conversationId, setConversationId] = useState<number | null>(null)
|
useState<ConnectionStatus>("disconnected");
|
||||||
|
const [conversationId, setConversationId] = useState<number | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const currentAssistantMessageRef = useRef<string | null>(null)
|
const currentAssistantMessageRef = useRef<string | null>(null);
|
||||||
const reconnectAttempts = useRef(0)
|
const reconnectAttempts = useRef(0);
|
||||||
const maxReconnectAttempts = 3
|
const maxReconnectAttempts = 3;
|
||||||
const pingIntervalRef = useRef<number | null>(null)
|
const pingIntervalRef = useRef<number | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Clean up on unmount
|
// Clean up on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current);
|
||||||
}
|
}
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
}
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close()
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
// Prevent multiple connection attempts
|
// Prevent multiple connection attempts
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN ||
|
if (
|
||||||
wsRef.current?.readyState === WebSocket.CONNECTING) {
|
wsRef.current?.readyState === WebSocket.OPEN ||
|
||||||
return
|
wsRef.current?.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnectionStatus('connecting')
|
setConnectionStatus("connecting");
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const host = window.location.host
|
const host = window.location.host;
|
||||||
const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}`
|
const wsUrl = `${protocol}//${host}/api/assistant/ws/${encodeURIComponent(projectName)}`;
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setConnectionStatus('connected')
|
setConnectionStatus("connected");
|
||||||
reconnectAttempts.current = 0
|
reconnectAttempts.current = 0;
|
||||||
|
|
||||||
// Start ping interval to keep connection alive
|
// Start ping interval to keep connection alive
|
||||||
pingIntervalRef.current = window.setInterval(() => {
|
pingIntervalRef.current = window.setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'ping' }))
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
}
|
}
|
||||||
}, 30000)
|
}, 30000);
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setConnectionStatus('disconnected')
|
setConnectionStatus("disconnected");
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt reconnection if not intentionally closed
|
// Attempt reconnection if not intentionally closed
|
||||||
if (reconnectAttempts.current < maxReconnectAttempts) {
|
if (reconnectAttempts.current < maxReconnectAttempts) {
|
||||||
reconnectAttempts.current++
|
reconnectAttempts.current++;
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
|
const delay = Math.min(
|
||||||
reconnectTimeoutRef.current = window.setTimeout(connect, delay)
|
1000 * Math.pow(2, reconnectAttempts.current),
|
||||||
|
10000,
|
||||||
|
);
|
||||||
|
reconnectTimeoutRef.current = window.setTimeout(connect, delay);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
setConnectionStatus('error')
|
setConnectionStatus("error");
|
||||||
onError?.('WebSocket connection error')
|
onError?.("WebSocket connection error");
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data) as AssistantChatServerMessage
|
const data = JSON.parse(event.data) as AssistantChatServerMessage;
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'text': {
|
case "text": {
|
||||||
// Append text to current assistant message or create new one
|
// Append text to current assistant message or create new one
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const lastMessage = prev[prev.length - 1]
|
const lastMessage = prev[prev.length - 1];
|
||||||
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
|
if (
|
||||||
|
lastMessage?.role === "assistant" &&
|
||||||
|
lastMessage.isStreaming
|
||||||
|
) {
|
||||||
// Append to existing streaming message
|
// Append to existing streaming message
|
||||||
return [
|
return [
|
||||||
...prev.slice(0, -1),
|
...prev.slice(0, -1),
|
||||||
@@ -123,155 +132,197 @@ export function useAssistantChat({
|
|||||||
...lastMessage,
|
...lastMessage,
|
||||||
content: lastMessage.content + data.content,
|
content: lastMessage.content + data.content,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
} else {
|
} else {
|
||||||
// Create new assistant message
|
// Create new assistant message
|
||||||
currentAssistantMessageRef.current = generateId()
|
currentAssistantMessageRef.current = generateId();
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: currentAssistantMessageRef.current,
|
id: currentAssistantMessageRef.current,
|
||||||
role: 'assistant',
|
role: "assistant",
|
||||||
content: data.content,
|
content: data.content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'tool_call': {
|
case "tool_call": {
|
||||||
|
// Generate user-friendly tool descriptions
|
||||||
|
let toolDescription = `Using tool: ${data.tool}`;
|
||||||
|
|
||||||
|
if (data.tool === "mcp__features__feature_create") {
|
||||||
|
const input = data.input as { name?: string; category?: string };
|
||||||
|
toolDescription = `Creating feature: "${input.name || "New Feature"}" in ${input.category || "General"}`;
|
||||||
|
} else if (data.tool === "mcp__features__feature_create_bulk") {
|
||||||
|
const input = data.input as {
|
||||||
|
features?: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
const count = input.features?.length || 0;
|
||||||
|
toolDescription = `Creating ${count} feature${count !== 1 ? "s" : ""}`;
|
||||||
|
} else if (data.tool === "mcp__features__feature_skip") {
|
||||||
|
toolDescription = `Skipping feature (moving to end of queue)`;
|
||||||
|
} else if (data.tool === "mcp__features__feature_get_stats") {
|
||||||
|
toolDescription = `Checking project progress`;
|
||||||
|
} else if (data.tool === "mcp__features__feature_get_next") {
|
||||||
|
toolDescription = `Getting next pending feature`;
|
||||||
|
} else if (data.tool === "Read") {
|
||||||
|
const input = data.input as { file_path?: string };
|
||||||
|
const path = input.file_path || "";
|
||||||
|
const filename = path.split("/").pop() || path;
|
||||||
|
toolDescription = `Reading file: ${filename}`;
|
||||||
|
} else if (data.tool === "Glob") {
|
||||||
|
const input = data.input as { pattern?: string };
|
||||||
|
toolDescription = `Searching for files: ${input.pattern || "..."}`;
|
||||||
|
} else if (data.tool === "Grep") {
|
||||||
|
const input = data.input as { pattern?: string };
|
||||||
|
toolDescription = `Searching for: ${input.pattern || "..."}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Show tool call as system message
|
// Show tool call as system message
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: 'system',
|
role: "system",
|
||||||
content: `Using tool: ${data.tool}`,
|
content: toolDescription,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'conversation_created': {
|
case "conversation_created": {
|
||||||
setConversationId(data.conversation_id)
|
setConversationId(data.conversation_id);
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'response_done': {
|
case "response_done": {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
|
|
||||||
// Mark current message as done streaming
|
// Mark current message as done streaming
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const lastMessage = prev[prev.length - 1]
|
const lastMessage = prev[prev.length - 1];
|
||||||
if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
|
if (
|
||||||
|
lastMessage?.role === "assistant" &&
|
||||||
|
lastMessage.isStreaming
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
...prev.slice(0, -1),
|
...prev.slice(0, -1),
|
||||||
{ ...lastMessage, isStreaming: false },
|
{ ...lastMessage, isStreaming: false },
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
return prev
|
return prev;
|
||||||
})
|
});
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'error': {
|
case "error": {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
onError?.(data.content)
|
onError?.(data.content);
|
||||||
|
|
||||||
// Add error as system message
|
// Add error as system message
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role: 'system',
|
role: "system",
|
||||||
content: `Error: ${data.content}`,
|
content: `Error: ${data.content}`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'pong': {
|
case "pong": {
|
||||||
// Keep-alive response, nothing to do
|
// Keep-alive response, nothing to do
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse WebSocket message:', e)
|
console.error("Failed to parse WebSocket message:", e);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [projectName, onError])
|
}, [projectName, onError]);
|
||||||
|
|
||||||
const start = useCallback((existingConversationId?: number | null) => {
|
const start = useCallback(
|
||||||
connect()
|
(existingConversationId?: number | null) => {
|
||||||
|
connect();
|
||||||
|
|
||||||
// Wait for connection then send start message
|
// Wait for connection then send start message
|
||||||
const checkAndSend = () => {
|
const checkAndSend = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
const payload: { type: string; conversation_id?: number } = { type: 'start' }
|
const payload: { type: string; conversation_id?: number } = {
|
||||||
if (existingConversationId) {
|
type: "start",
|
||||||
payload.conversation_id = existingConversationId
|
};
|
||||||
setConversationId(existingConversationId)
|
if (existingConversationId) {
|
||||||
|
payload.conversation_id = existingConversationId;
|
||||||
|
setConversationId(existingConversationId);
|
||||||
|
}
|
||||||
|
wsRef.current.send(JSON.stringify(payload));
|
||||||
|
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||||
|
setTimeout(checkAndSend, 100);
|
||||||
}
|
}
|
||||||
wsRef.current.send(JSON.stringify(payload))
|
};
|
||||||
} else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
|
||||||
setTimeout(checkAndSend, 100)
|
setTimeout(checkAndSend, 100);
|
||||||
|
},
|
||||||
|
[connect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
|
onError?.("Not connected");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(checkAndSend, 100)
|
// Add user message to chat
|
||||||
}, [connect])
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const sendMessage = useCallback((content: string) => {
|
setIsLoading(true);
|
||||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
||||||
onError?.('Not connected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add user message to chat
|
// Send to server
|
||||||
setMessages((prev) => [
|
wsRef.current.send(
|
||||||
...prev,
|
JSON.stringify({
|
||||||
{
|
type: "message",
|
||||||
id: generateId(),
|
content,
|
||||||
role: 'user',
|
}),
|
||||||
content,
|
);
|
||||||
timestamp: new Date(),
|
},
|
||||||
},
|
[onError],
|
||||||
])
|
);
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
// Send to server
|
|
||||||
wsRef.current.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'message',
|
|
||||||
content,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, [onError])
|
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection
|
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close()
|
wsRef.current.close();
|
||||||
wsRef.current = null
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
setConnectionStatus('disconnected')
|
setConnectionStatus("disconnected");
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const clearMessages = useCallback(() => {
|
const clearMessages = useCallback(() => {
|
||||||
setMessages([])
|
setMessages([]);
|
||||||
setConversationId(null)
|
setConversationId(null);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
@@ -282,5 +333,5 @@ export function useAssistantChat({
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
disconnect,
|
disconnect,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user