Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
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 (any user) / create (staff) the shared 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. Admin-managed **Chat Profiles** (Django `is_staff`) restrict which MCP tools it may use and can override the system prompt; a default profile can be enforced for non-staff users (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
98 changes: 98 additions & 0 deletions docs/CHAT_PROFILES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Chat Profiles (browser chat)

Chat Profiles control which MCP tools the browser **AI Assistant** may use and
optionally override its system prompt. Profiles are **shared presets managed by
administrators**: staff create and edit them under Settings, and every user picks
one from the chat header.

## Concepts

| Term | Meaning |
|---|---|
| **Default** (no profile) | Unchanged behaviour: every MCP tool the server advertises is offered and `DEFAULT_SYSTEM_PROMPT` is used. Available to staff, and to everyone while no default profile is set. |
| **Default profile** | At most one profile flagged `is_default`. While it is set, non-staff users cannot pick "Default (all tools)"; the backend applies the default profile whenever a non-staff request carries no `profile_id`. |
| **Admin / staff** | A Django user with `is_staff = True` (the same flag that gates custom-database management). Only staff may create, edit, delete or flag profiles. |
| `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`. A **new** profile starts with every tool currently
listed in the picker checked; the user can still Select none (`allowed_tools =
[]` remains valid).

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.

"Default (all tools)" exposes **every** tool the MCP server advertises,
including any that manage user credentials or other sensitive state. Set a
default profile with an explicit allowlist to keep such tools away from
non-staff chats.

Chat Profiles apply only to the browser AI Assistant (`POST /api/chat/stream/`).
The Jupyter notebook agent and its proxies (`GET /api/chat/mcp-tools/` and
`POST /api/chat/mcp-call/`) do not use profiles.

## Granting admin rights

Users are provisioned in Django automatically on their first Keycloak login, so
flag the existing row rather than creating a new account. From `gui/`:

```bash
docker-compose exec backend python django-project/manage.py shell -c \
"from django.contrib.auth import get_user_model as U; u = U().objects.get(email='alice@example.com'); u.is_staff = True; u.save()"
```

Alternatively tick **Staff status** on the user in the Django admin (`/admin/`;
creating the superuser needed to log in there is described in
`docs/RECOVER_LEGACY_PROJECTS.md`, Step 1).

## Using it

1. **Settings → Chat Profiles** (`/settings/chat-profiles`), as staff: 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*). The star button flags a profile as the default (or clears
it). Non-staff users see the same page read-only.
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.
Non-staff users start on the default profile when one is set, and the
"Default (all tools)" entry is hidden from them.
3. The **Generate report** button is disabled when the selected profile lacks
`get_workflow_facts` or `save_report`.

## API

| Method | Endpoint | Who | Notes |
|---|---|---|---|
| GET | `/api/chat/profiles/` | any user | List the shared profiles |
| POST | `/api/chat/profiles/` | staff | Create. Body: `{name, allowed_tools: string[], system_prompt, is_default?}`. Names are unique; `system_prompt` ≤ 16000 chars |
| GET | `/api/chat/profiles/<uuid>/` | any user | Retrieve one profile |
| PUT / DELETE | `/api/chat/profiles/<uuid>/` | staff | PUT is partial; `is_default: true` clears the flag on every other profile. DELETE returns 204. Non-staff get 403 |
| POST | `/api/chat/stream/` | any user | Optional `profile_id` (unknown id → 404 before any conversation is created). Without it, non-staff users get the default profile if one is set |
| GET | `/api/profile/` | any user | Returns `user.is_staff`, which the frontend uses to decide what to show |
| GET | `/api/chat/mcp-tools/` | any user | 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` — staff-only
writes via `IsAdminUser`; `ChatStreamView` applies the default profile),
`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`.
1 change: 1 addition & 0 deletions gui/workflow_backend/django-project/app/auth/authViews.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def user_profile(request):
"email": request.user.email,
"first_name": request.user.first_name,
"last_name": request.user.last_name,
"is_staff": request.user.is_staff,
"date_joined": request.user.date_joined.isoformat(),
"last_login": (
request.user.last_login.isoformat()
Expand Down
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", "is_default", "created_by", "updated_at"]
search_fields = ["name"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 5.2 on 2026-09-14 00:00

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, unique=True)),
('allowed_tools', models.JSONField(blank=True, default=list)),
('system_prompt', models.TextField(blank=True, default='')),
('is_default', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chat_profiles', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'chat_profiles',
'ordering': ['name'],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('is_default',), name='chat_profile_single_default')],
},
),
]
36 changes: 36 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,39 @@ def to_openai_format(self):
msg["tool_call_id"] = self.tool_call_id
msg["name"] = self.tool_name
return msg


class ChatProfile(models.Model):
"""Admin-managed preset for the browser chat: which MCP tools the
assistant may use and an optional system prompt override. Profiles are
shared by every user; only staff can create or change them. At most one
profile is the default, which non-staff users get when they do not pick
one explicitly."""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=100, unique=True)
# 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="")
is_default = models.BooleanField(default=False)
created_by = models.ForeignKey(
User, on_delete=models.SET_NULL, related_name="chat_profiles",
null=True, blank=True,
)
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=["is_default"],
condition=models.Q(is_default=True),
name="chat_profile_single_default",
)
]

def __str__(self):
return self.name
78 changes: 77 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,8 @@
from rest_framework import serializers
from .models import Conversation, Message
from .models import ChatProfile, Conversation, Message

# Keep in sync with ChatProfileModal textarea maxLength.
SYSTEM_PROMPT_MAX_LENGTH = 16000


class MessageSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -71,3 +74,76 @@ 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):
# Declared explicitly so the model's unique=True does not add DRF's
# UniqueValidator; validate_name gives the friendlier message and the DB
# constraint (IntegrityError -> 400 in the view) covers races.
name = serializers.CharField(max_length=100)
allowed_tools = serializers.ListField(
child=serializers.CharField(max_length=255, allow_blank=False),
allow_empty=True,
)
system_prompt = serializers.CharField(
required=False,
allow_blank=True,
max_length=SYSTEM_PROMPT_MAX_LENGTH,
)
is_default = serializers.BooleanField(required=False)

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

def validate_allowed_tools(self, value):
cleaned = []
for name in value:
stripped = name.strip()
if not stripped:
raise serializers.ValidationError("Tool names cannot be empty.")
cleaned.append(stripped)
# Drop duplicates while keeping the submitted order.
return list(dict.fromkeys(cleaned))

def validate_name(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError("Name is required.")
qs = ChatProfile.objects.filter(name=value)
if self.instance is not None:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError(
"A profile with this name already exists."
)
return value

def _clear_other_defaults(self, validated_data, instance=None):
# Only one profile can be the default; the view wraps save() in a
# transaction so this and the insert/update commit together.
if validated_data.get("is_default"):
qs = ChatProfile.objects.filter(is_default=True)
if instance is not None:
qs = qs.exclude(pk=instance.pk)
qs.update(is_default=False)

def create(self, validated_data):
self._clear_other_defaults(validated_data)
return super().create(validated_data)

def update(self, instance, validated_data):
self._clear_other_defaults(validated_data, instance)
return super().update(instance, validated_data)
Loading