Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ src/neuroworkflow/nodes/sandbox/
# Remote (Slurm) execution runtime dirs: per-run staged inputs + fetched
# results, co-located under each project (codes/projects/<id>/batch/<run_id>/)
gui/workflow_backend/django-project/codes/projects/*/batch/

# Claude Code personal overrides
.claude/settings.local.json
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ isort --profile black src/
| GET/POST | `/api/workflow/{id}/edges/` | List/create edges |
| POST | `/api/workflow/{id}/generate-code/` | Generate Python code from workflow |
| POST | `/api/workflow/{id}/run/` | Execute workflow (streaming) |
| GET/POST | `/api/chat/profiles/` | List/create the user's chat profiles (MCP tool allowlist + prompt override) |

## Environment Variables

Expand All @@ -131,7 +132,7 @@ Template: `gui/workflow_backend/env.template`
- Core library code in `src/neuroworkflow/core/` is also synced to `gui/workflow_backend/django-project/codes/neuroworkflow/core/`.
- Workflow execution uses JupyterHub's kernel WebSocket API — code is generated from the node graph and sent to a Jupyter kernel for execution.
- Authentication is handled by Keycloak (OIDC). The frontend uses `keycloak-js` (`onLoad: "login-required"`); the backend verifies access tokens via the realm's JWKS endpoint in `app/auth/authentication.py:KeycloakAuthentication`.
- The **browser chat** feature uses the OpenAI API with Function Calling and MCP integration.
- The **browser chat** feature uses the OpenAI API with Function Calling and MCP integration. Per-user **Chat Profiles** restrict which MCP tools it may use and can override the system prompt (see `docs/CHAT_PROFILES.md`).
- The **in-notebook chat agent** (`src/neuroworkflow/agent/`, synced to `codes/neuroworkflow/agent/`) uses the **Claude Agent SDK** running in the Jupyter kernel. It reaches Anthropic through the backend `/api/chat/anthropic` proxy (`ANTHROPIC_BASE_URL`), so the API key stays on the backend; workflow tools still go through the MCP proxies with the user's Keycloak token. The `claude` CLI + `claude-agent-sdk` are bundled in the nest kernel image (`Dockerfile.nest`). See `docs/NOTEBOOK_CHAT_AGENT.md`.

## Code Style
Expand Down
5 changes: 3 additions & 2 deletions docs/BRAIN_VIEWER_CHAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ Key points:

- **The chat reuses the existing browser chat** (OpenAI + Function Calling + MCP,
authenticated with the user's Keycloak token). The viewer tools are ordinary
MCP tools, so the chat agent sees them automatically.
MCP tools, so the chat agent sees them automatically (unless the selected
Chat Profile restricts tools — see `docs/CHAT_PROFILES.md`).
- **Compute lives in Django** (numpy is available there; the MCP server is a thin
HTTP proxy). The `viewer_*` MCP tools forward to an authenticated Django
endpoint that loads the run's data and runs the vendored functions.
Expand Down Expand Up @@ -230,7 +231,7 @@ To add a tool:
| Symptom | Cause / fix |
|---|---|
| `{"status": "no_viewer_data", …}` | No `connectivity_data.json` / `human_data.json` under the project's `results/viewer/`, or a wrong `data_path`. Run the viewer node; verify the file with a direct `curl` to `/api/workflow/<id>/viewer-chat/`. |
| Tools never fire | `OPENAI_API_KEY` not set, or the MCP server is down (backend logs: "Failed to get MCP tools"). |
| Tools never fire | `OPENAI_API_KEY` not set, or the MCP server is down (backend logs: "Failed to get MCP tools"). Also check the Chat Profile selected in the chat header — a profile with no or limited tools hides them (see `docs/CHAT_PROFILES.md`). |
| Explanation works but the 3D scene doesn't move | The `brain_viewer.js` module is cached in the browser (it loads without a cache-buster). Close and reopen the viewer tab, or hard-reload (Cmd+Shift+R). Confirm the served file is current: `fetch('/static/viewer/brain_viewer.js').then(r=>r.text()).then(t=>console.log(t.includes('nw-viewer')))`. |
| Chat says "nothing is selected" after you clicked a sphere | The viewer's own selection panel must show the region first (confirm the click hit a sphere). If it does but the chat still doesn't know, hard-reload the viewer (stale JS module). |
| Region names don't resolve | Ask the assistant to search first, or give an exact label (`L_A10`). Human runs with `meta.species = null` will mis-map to the marmoset lookup — ensure the node writes `meta.species`. |
Expand Down
62 changes: 62 additions & 0 deletions docs/CHAT_PROFILES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Chat Profiles (browser chat)

Chat Profiles let each user control which MCP tools the browser **AI Assistant**
may use, and optionally override its system prompt. Profiles are stored per user
on the backend and switched from the chat header.

## Concepts

| Term | Meaning |
|---|---|
| **Default** (no profile) | Unchanged behaviour: every MCP tool is offered and `DEFAULT_SYSTEM_PROMPT` is used. |
| `allowed_tools` | Explicit allowlist of MCP tool names. Only these tools are offered to OpenAI **and** allowed to execute. |
| `allowed_tools = []` | Tools disabled: the backend skips MCP discovery entirely and appends `TOOLS_DISABLED_NOTE` to the system prompt. |
| `system_prompt` | Optional override. Precedence: profile prompt > `Conversation.system_prompt` > `DEFAULT_SYSTEM_PROMPT`. |

Because the allowlist is explicit, **new MCP tools start unchecked in existing
profiles**. They appear under "Other" in the editor until they are categorised
in `chatToolCategories.ts`.

When a profile restricts (but does not disable) tools, the backend appends
`TOOLS_RESTRICTED_NOTE` listing the enabled tools, because the default prompt
refers to tools by name.

## Using it

1. **Settings → Chat Profiles** (`/settings/chat-profiles`): create a profile —
name, optional system prompt, and the tool picker (grouped by category, with
per-category and per-tool checkboxes plus *Select all* / *Select none*).
2. In the chat header, pick the profile from the dropdown next to the
conversation selector. The selection is remembered per user in this browser
(`localStorage` key `chatProfileId:<user key>`, where the key is the Keycloak
`sub`, falling back to `preferred_username` / email when the access token
carries no `sub` — the same order the backend maps users by) and sent as
`profile_id` with every message, so it can be switched mid-conversation.
3. The **Generate report** button is disabled when the selected profile lacks
`get_workflow_facts` or `save_report`.

## API

| Method | Endpoint | Notes |
|---|---|---|
| GET / POST | `/api/chat/profiles/` | List / create the caller's profiles. Body: `{name, allowed_tools: string[], system_prompt}` |
| GET / PUT / DELETE | `/api/chat/profiles/<uuid>/` | Owner-scoped (404 otherwise). PUT is partial. DELETE returns 204 |
| POST | `/api/chat/stream/` | Accepts optional `profile_id`; an unknown or foreign id returns 404 before any conversation is created |
| GET | `/api/chat/mcp-tools/` | Tool catalog used by the editor (shared with the notebook agent; shape unchanged) |

## Code map

Backend (`gui/workflow_backend/django-project/app/chat/`):
`models.py` (`ChatProfile`, migration `0002_chatprofile`),
`serializers.py` (`ChatProfileSerializer`, `SendMessageSerializer.profile_id`),
`views.py` (`ChatProfileListCreateView`, `ChatProfileDetailView`, `ChatStreamView`),
`services/mcp_client.py` (`mcp_tools_to_openai_functions(..., allowed=)`),
`services/chat_orchestrator.py` (`orchestrate_chat(..., profile=)`).

Frontend (`gui/workflow_frontend/src/`):
`api/chatProfileApi.ts`, `stores/chatProfileStore.ts`,
`views/home/components/ChatProfileSelector.tsx`, `ChatProfileManager.tsx`,
`ChatProfileModal.tsx`, `chatToolCategories.ts`; wired in `chatbotView.tsx`,
`components/tabs/TabManager.tsx` and `shared/header/header.tsx`.

Tests: `gui/workflow_backend/django-project/tests/test_chat_profiles.py`.
8 changes: 7 additions & 1 deletion gui/workflow_backend/django-project/app/chat/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Conversation, Message
from .models import ChatProfile, Conversation, Message


class MessageInline(admin.TabularInline):
Expand All @@ -24,3 +24,9 @@ class MessageAdmin(admin.ModelAdmin):
def content_preview(self, obj):
return obj.content[:100] if obj.content else ""
content_preview.short_description = "Content"


@admin.register(ChatProfile)
class ChatProfileAdmin(admin.ModelAdmin):
list_display = ["name", "user", "updated_at"]
search_fields = ["name"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2 on 2026-09-05 08:57

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('chat', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='ChatProfile',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('allowed_tools', models.JSONField(blank=True, default=list)),
('system_prompt', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat_profiles', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'chat_profiles',
'ordering': ['name'],
'constraints': [models.UniqueConstraint(fields=('user', 'name'), name='chat_profile_unique_user_name')],
},
),
]
29 changes: 29 additions & 0 deletions gui/workflow_backend/django-project/app/chat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,32 @@ def to_openai_format(self):
msg["tool_call_id"] = self.tool_call_id
msg["name"] = self.tool_name
return msg


class ChatProfile(models.Model):
"""Per-user preset for the browser chat: which MCP tools the assistant may
use and an optional system prompt override."""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="chat_profiles",
)
name = models.CharField(max_length=100)
# Explicit allowlist of MCP tool names. An empty list disables tools.
allowed_tools = models.JSONField(default=list, blank=True)
# Empty means "use the default assistant prompt".
system_prompt = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
db_table = "chat_profiles"
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["user", "name"], name="chat_profile_unique_user_name",
)
]

def __str__(self):
return f"{self.name} ({self.user_id})"
42 changes: 41 additions & 1 deletion gui/workflow_backend/django-project/app/chat/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import Conversation, Message
from .models import ChatProfile, Conversation, Message


class MessageSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -71,3 +71,43 @@ class SendMessageSerializer(serializers.Serializer):
viewer_context = serializers.CharField(
required=False, allow_blank=True, allow_null=True
)
# Chat profile (per-user MCP tool allowlist + system prompt override).
# Omitted / null means the default behaviour: all tools, default prompt.
profile_id = serializers.UUIDField(required=False, allow_null=True)
Comment thread
Copilot marked this conversation as resolved.
Outdated


class ChatProfileSerializer(serializers.ModelSerializer):
allowed_tools = serializers.ListField(
child=serializers.CharField(max_length=255), allow_empty=True
)

class Meta:
model = ChatProfile
fields = [
"id",
"name",
"allowed_tools",
"system_prompt",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]

def validate_allowed_tools(self, value):
# Drop duplicates while keeping the submitted order.
return list(dict.fromkeys(value))

def validate_name(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError("Name is required.")
qs = ChatProfile.objects.filter(
user=self.context["request"].user, name=value
)
if self.instance is not None:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError(
"You already have a profile with this name."
)
return value
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@

MAX_AGENT_LOOPS = 10

# Appended to the system prompt when the selected chat profile disables or
# restricts tools, because DEFAULT_SYSTEM_PROMPT refers to tools by name.
TOOLS_DISABLED_NOTE = (
"\n\nNOTE: Tools are disabled in this conversation. Answer from your own "
"knowledge and the provided context, and tell the user when a request "
"would require a tool."
)
TOOLS_RESTRICTED_NOTE = (
"\n\nNOTE: Only these tools are enabled in this conversation: {tools}. "
"Ignore any instruction above that refers to other tools; tell the user "
"when a request would require a tool that is not enabled."
)


@sync_to_async
def _create_message(**kwargs):
Expand All @@ -77,13 +90,23 @@ def _create_message(**kwargs):

@sync_to_async
def _build_openai_messages(
conversation: Conversation, viewer_context: str | None = None
conversation: Conversation,
viewer_context: str | None = None,
profile=None,
) -> list[dict]:
"""Build the OpenAI messages array from conversation history."""
"""Build the OpenAI messages array from conversation history.

``profile`` is an optional ChatProfile; its system prompt (if any) wins
over the conversation's, which wins over DEFAULT_SYSTEM_PROMPT.
"""
messages = []

# System prompt — inject active project context if available
system_prompt = conversation.system_prompt or DEFAULT_SYSTEM_PROMPT
system_prompt = (
(profile.system_prompt if profile is not None else "")
or conversation.system_prompt
or DEFAULT_SYSTEM_PROMPT
)
if conversation.project_id:
try:
project = conversation.project
Expand All @@ -94,6 +117,13 @@ def _build_openai_messages(
system_prompt = system_prompt + project_context
except Exception:
pass
if profile is not None:
if not profile.allowed_tools:
system_prompt += TOOLS_DISABLED_NOTE
else:
system_prompt += TOOLS_RESTRICTED_NOTE.format(
tools=", ".join(profile.allowed_tools)
)
messages.append({"role": "system", "content": system_prompt})

# Ephemeral brain-viewer state (what the user currently sees). Rebuilt each
Expand All @@ -117,12 +147,16 @@ async def orchestrate_chat(
user_message: str,
auth_token: str | None = None,
viewer_context: str | None = None,
profile=None,
):
"""Run the agent loop: LLM -> tool calls -> LLM -> ... -> final response.

``auth_token`` is the end-user's bearer JWT, forwarded through MCPClient
so workflow_mcp tools can present it when calling the Django API.

``profile`` is an optional ChatProfile restricting which MCP tools are
offered (and allowed to run). An empty allowlist skips MCP entirely.

This is an async generator that yields SSE event dicts.
"""
# 1. Save user message
Expand All @@ -132,24 +166,29 @@ async def orchestrate_chat(
content=user_message,
)

# 2. Initialize MCP client and get tools
mcp = MCPClient(auth_token=auth_token)
try:
await mcp.initialize()
except Exception as e:
logger.warning("MCP initialize failed (may already be initialized): %s", e)
# 2. Initialize MCP client and get tools (skipped when the profile
# disables tools, so no MCP round-trips are made in that case)
allowed = set(profile.allowed_tools) if profile is not None else None
mcp = None
openai_tools = []
if allowed is None or allowed:
mcp = MCPClient(auth_token=auth_token)
try:
await mcp.initialize()
except Exception as e:
logger.warning("MCP initialize failed (may already be initialized): %s", e)

try:
mcp_tools = await mcp.list_tools()
openai_tools = mcp_tools_to_openai_functions(mcp_tools)
except Exception as e:
logger.error("Failed to get MCP tools: %s", e)
openai_tools = []
try:
mcp_tools = await mcp.list_tools()
openai_tools = mcp_tools_to_openai_functions(mcp_tools, allowed=allowed)
except Exception as e:
logger.error("Failed to get MCP tools: %s", e)
openai_tools = []

# 3. Agent loop
for loop_idx in range(MAX_AGENT_LOOPS):
# Build message history for OpenAI
messages = await _build_openai_messages(conversation, viewer_context)
messages = await _build_openai_messages(conversation, viewer_context, profile)

# Stream OpenAI response
full_content = ""
Expand Down Expand Up @@ -227,11 +266,17 @@ async def orchestrate_chat(
except json.JSONDecodeError:
arguments = {}

try:
result = await mcp.call_tool(tool_name, arguments)
except Exception as e:
result = f"Error executing tool: {str(e)}"
logger.error("MCP tool call error for %s: %s", tool_name, e)
if allowed is not None and tool_name not in allowed:
result = (
f"Error: tool '{tool_name}' is not enabled in the "
"current chat profile."
)
else:
try:
result = await mcp.call_tool(tool_name, arguments)
except Exception as e:
result = f"Error executing tool: {str(e)}"
logger.error("MCP tool call error for %s: %s", tool_name, e)

# Save tool result message
await _create_message(
Expand Down
Loading