Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e93e962e | ||
|
|
4199527372 |
@@ -26,6 +26,7 @@ SECRET_KEY = 'django-insecure-0$+hho_6%-(ud^t%0zos(q&i@2&)9m+u&dgj77&51g$m#hr^0s
|
|||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
TIANJI_ENABLED = not DEBUG
|
TIANJI_ENABLED = not DEBUG
|
||||||
|
WEBMCP_ENABLED = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ TEMPLATES = [
|
|||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'public.context_processors.tianji_tracking',
|
'public.context_processors.tianji_tracking',
|
||||||
|
'public.context_processors.webmcp_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,8 +25,17 @@ Test at minimum:
|
|||||||
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
|
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
|
||||||
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
|
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
|
||||||
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
|
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
|
||||||
|
| **WebMCP tools** | `navigator.modelContext` tools behind `WEBMCP_ENABLED` (see [webmcp.md](webmcp.md)) |
|
||||||
|
|
||||||
WebMCP is intentionally deferred until browser and agent support stabilizes.
|
## WebMCP (issue #9)
|
||||||
|
|
||||||
|
When `WEBMCP_ENABLED=True`:
|
||||||
|
|
||||||
|
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
|
||||||
|
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
|
||||||
|
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
|
||||||
|
|
||||||
|
Full tool catalog, Chrome flag setup, and reCAPTCHA notes: **[docs/webmcp.md](webmcp.md)**
|
||||||
|
|
||||||
## Regression checklist
|
## Regression checklist
|
||||||
|
|
||||||
@@ -36,10 +45,12 @@ Before merging public-facing template or CSS changes:
|
|||||||
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
|
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
|
||||||
3. Cookie consent banner stays `position: fixed` (no document flow shift).
|
3. Cookie consent banner stays `position: fixed` (no document flow shift).
|
||||||
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
|
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
|
||||||
5. Re-run Lighthouse agentic-browsing on homepage and contact page.
|
5. With `WEBMCP_ENABLED=True`, public pages include `webmcp-config` and `webmcp-tools.js`; contact form always has `toolname`, `tooldescription`, and `toolparamdescription` on fields.
|
||||||
|
6. Re-run Lighthouse agentic-browsing on homepage and contact page (Chrome experimental flag on).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
|
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
|
||||||
- [llms.txt proposal](https://llmstxt.org/)
|
- [llms.txt proposal](https://llmstxt.org/)
|
||||||
- Gitea issue #5
|
- [WebMCP tool catalog](webmcp.md)
|
||||||
|
- Gitea issue #5 (foundational), issue #9 (WebMCP)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# WebMCP Tool Catalog
|
||||||
|
|
||||||
|
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
|
||||||
|
|
||||||
|
## Enablement
|
||||||
|
|
||||||
|
### 1. Django setting
|
||||||
|
|
||||||
|
```python
|
||||||
|
# company_site/settings.py (or environment-specific settings)
|
||||||
|
WEBMCP_ENABLED = True
|
||||||
|
```
|
||||||
|
|
||||||
|
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
|
||||||
|
|
||||||
|
### 2. Chrome experimental flag
|
||||||
|
|
||||||
|
WebMCP requires the experimental web platform features flag:
|
||||||
|
|
||||||
|
1. Open `chrome://flags/#enable-experimental-web-platform-features`
|
||||||
|
2. Set **Enable experimental web platform features** to **Enabled**
|
||||||
|
3. Restart Chrome
|
||||||
|
|
||||||
|
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
|
||||||
|
|
||||||
|
### 3. Lighthouse audit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx lighthouse@latest https://aimloperations.com/contact \
|
||||||
|
--only-categories=agentic-browsing \
|
||||||
|
--chrome-flags="--enable-experimental-web-platform-features" \
|
||||||
|
--output=html --output-path=agentic-browsing-contact.html
|
||||||
|
|
||||||
|
npx lighthouse@latest https://aimloperations.com/ \
|
||||||
|
--only-categories=agentic-browsing \
|
||||||
|
--chrome-flags="--enable-experimental-web-platform-features" \
|
||||||
|
--output=html --output-path=agentic-browsing-home.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
|
||||||
|
|
||||||
|
## Registered tools
|
||||||
|
|
||||||
|
| Tool | Pages | readOnly | Description |
|
||||||
|
|------|-------|----------|-------------|
|
||||||
|
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
|
||||||
|
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
|
||||||
|
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
|
||||||
|
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
|
||||||
|
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
|
||||||
|
|
||||||
|
### `submit_contact_inquiry`
|
||||||
|
|
||||||
|
**Input schema:**
|
||||||
|
|
||||||
|
| Field | Type | Required |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `name` | string | Yes |
|
||||||
|
| `email` | string | Yes |
|
||||||
|
| `subject` | string | Yes |
|
||||||
|
| `message` | string | No |
|
||||||
|
|
||||||
|
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
|
||||||
|
|
||||||
|
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
|
||||||
|
|
||||||
|
### Declarative form annotation
|
||||||
|
|
||||||
|
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form id="contact-form" toolname="submit_contact_inquiry"
|
||||||
|
tooldescription="Submit a contact inquiry to AI ML Operations">
|
||||||
|
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
|
||||||
|
...
|
||||||
|
</form>
|
||||||
|
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
|
||||||
|
|
||||||
|
## Security scope
|
||||||
|
|
||||||
|
- Tools run in the visitor's browser session (no API keys)
|
||||||
|
- Only unauthenticated marketing flows are exposed
|
||||||
|
- Planning, financial, and admin actions are **not** registered
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Feature-detect (Chrome with experimental flag)
|
||||||
|
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
|
||||||
|
```
|
||||||
|
|
||||||
|
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
|
||||||
|
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
|
||||||
|
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
|
||||||
|
- Gitea issue #9
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
|
||||||
|
|
||||||
|
|
||||||
def tianji_tracking(request):
|
def tianji_tracking(request):
|
||||||
@@ -16,3 +21,32 @@ def tianji_tracking(request):
|
|||||||
),
|
),
|
||||||
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
|
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def webmcp_context(request):
|
||||||
|
page_name = getattr(getattr(request, 'resolver_match', None), 'url_name', '')
|
||||||
|
services = [
|
||||||
|
{
|
||||||
|
**entry,
|
||||||
|
"url": request.build_absolute_uri(reverse(entry["slug"])),
|
||||||
|
}
|
||||||
|
for entry in get_service_entries()
|
||||||
|
]
|
||||||
|
page_lookup = {
|
||||||
|
url_name: {
|
||||||
|
"slug": url_name,
|
||||||
|
"name": title,
|
||||||
|
"summary": summary,
|
||||||
|
"url": request.build_absolute_uri(reverse(url_name)),
|
||||||
|
}
|
||||||
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
|
||||||
|
'webmcp_page_name': page_name,
|
||||||
|
'webmcp_contact_url': request.build_absolute_uri(reverse('contact')),
|
||||||
|
'webmcp_recaptcha_required': not settings.DEBUG,
|
||||||
|
'webmcp_services_json': json.dumps(services),
|
||||||
|
'webmcp_pages_json': json.dumps(page_lookup),
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ class FormWithCaptcha(forms.Form):
|
|||||||
captcha = ReCaptchaField(
|
captcha = ReCaptchaField(
|
||||||
widget=ReCaptchaV3(
|
widget=ReCaptchaV3(
|
||||||
attrs={
|
attrs={
|
||||||
'required_score':0.85,
|
'required_score': 0.85,
|
||||||
}
|
'form': 'contact-form',
|
||||||
|
}
|
||||||
),
|
),
|
||||||
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
||||||
private_key=settings.RECAPTCHA_PRIVATE_KEY,
|
private_key=settings.RECAPTCHA_PRIVATE_KEY,
|
||||||
|
|||||||
+112
-14
@@ -5,21 +5,119 @@ from django.template.loader import render_to_string
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
# Public marketing pages included in sitemap and llms.txt.
|
# Public marketing pages included in sitemap and llms.txt.
|
||||||
|
# Tuple: (url_name, title, changefreq, priority, summary)
|
||||||
PUBLIC_PAGE_ENTRIES = (
|
PUBLIC_PAGE_ENTRIES = (
|
||||||
("public_index", "Home", "weekly", "1.0"),
|
(
|
||||||
("forward_deployed", "Forward-Deployed AI", "monthly", "0.9"),
|
"public_index",
|
||||||
("bot", "AI Agents", "monthly", "0.9"),
|
"Home",
|
||||||
("ml_model", "ML Models", "monthly", "0.8"),
|
"weekly",
|
||||||
("chat", "Secure AI Chat", "monthly", "0.8"),
|
"1.0",
|
||||||
("ai_sensor", "AI Sensor Algorithms", "monthly", "0.7"),
|
"Company homepage with an overview of AI ML Operations services.",
|
||||||
("ai_education", "AI Education", "monthly", "0.7"),
|
),
|
||||||
("computers", "Computer Builds", "monthly", "0.7"),
|
(
|
||||||
("file_hosting", "File Hosting", "monthly", "0.7"),
|
"forward_deployed",
|
||||||
("web_design", "Web Design and Hosting", "monthly", "0.8"),
|
"Forward-Deployed AI",
|
||||||
("contact", "Contact", "monthly", "0.9"),
|
"monthly",
|
||||||
("terms_of_service", "Terms of Service and Privacy", "yearly", "0.3"),
|
"0.9",
|
||||||
|
"Embedded AI engineering — we work inside your environment to build production systems.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bot",
|
||||||
|
"AI Agents",
|
||||||
|
"monthly",
|
||||||
|
"0.9",
|
||||||
|
"Custom AI agents and agentic workflows tailored to your operational bottlenecks.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ml_model",
|
||||||
|
"ML Models",
|
||||||
|
"monthly",
|
||||||
|
"0.8",
|
||||||
|
"Machine learning model development, training, and deployment for production use.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"chat",
|
||||||
|
"Secure AI Chat",
|
||||||
|
"monthly",
|
||||||
|
"0.8",
|
||||||
|
"Private, hosted AI chat deployments with enterprise-grade security.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ai_sensor",
|
||||||
|
"AI Sensor Algorithms",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Computer vision and sensor-fusion algorithms for real-world sensing applications.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ai_education",
|
||||||
|
"AI Education",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Hands-on AI training and workshops for teams adopting agentic workflows.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"computers",
|
||||||
|
"Computer Builds",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Custom workstation and server builds optimized for AI and ML workloads.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file_hosting",
|
||||||
|
"File Hosting",
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
"Managed file hosting and storage for teams that need reliable data access.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"web_design",
|
||||||
|
"Web Design and Hosting",
|
||||||
|
"monthly",
|
||||||
|
"0.8",
|
||||||
|
"Web design, development, and managed hosting for business sites and apps.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"contact",
|
||||||
|
"Contact",
|
||||||
|
"monthly",
|
||||||
|
"0.9",
|
||||||
|
"Contact form to inquire about forward-deployed AI engineering services.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"terms_of_service",
|
||||||
|
"Terms of Service and Privacy",
|
||||||
|
"yearly",
|
||||||
|
"0.3",
|
||||||
|
"Terms of service and privacy policy for AI ML Operations, LLC.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SERVICE_URL_NAMES = frozenset({
|
||||||
|
"forward_deployed",
|
||||||
|
"bot",
|
||||||
|
"ml_model",
|
||||||
|
"chat",
|
||||||
|
"ai_sensor",
|
||||||
|
"ai_education",
|
||||||
|
"computers",
|
||||||
|
"file_hosting",
|
||||||
|
"web_design",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_entries():
|
||||||
|
"""Return service page metadata for WebMCP navigation tools."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slug": url_name,
|
||||||
|
"name": title,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
|
if url_name in SERVICE_URL_NAMES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _absolute_url(request, url_name):
|
def _absolute_url(request, url_name):
|
||||||
return request.build_absolute_uri(reverse(url_name))
|
return request.build_absolute_uri(reverse(url_name))
|
||||||
@@ -41,7 +139,7 @@ def sitemap_xml(request):
|
|||||||
"changefreq": changefreq,
|
"changefreq": changefreq,
|
||||||
"priority": priority,
|
"priority": priority,
|
||||||
}
|
}
|
||||||
for url_name, _title, changefreq, priority in PUBLIC_PAGE_ENTRIES
|
for url_name, _title, changefreq, priority, _summary in PUBLIC_PAGE_ENTRIES
|
||||||
]
|
]
|
||||||
content = render_to_string("public/sitemap.xml", {"pages": pages})
|
content = render_to_string("public/sitemap.xml", {"pages": pages})
|
||||||
return HttpResponse(content, content_type="application/xml; charset=utf-8")
|
return HttpResponse(content, content_type="application/xml; charset=utf-8")
|
||||||
@@ -53,7 +151,7 @@ def llms_txt(request):
|
|||||||
"title": title,
|
"title": title,
|
||||||
"url": _absolute_url(request, url_name),
|
"url": _absolute_url(request, url_name),
|
||||||
}
|
}
|
||||||
for url_name, title, _changefreq, _priority in PUBLIC_PAGE_ENTRIES
|
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
|
||||||
]
|
]
|
||||||
content = render_to_string(
|
content = render_to_string(
|
||||||
"public/llms.txt",
|
"public/llms.txt",
|
||||||
|
|||||||
@@ -780,6 +780,14 @@ input:focus, select:focus, textarea:focus {
|
|||||||
margin: 0 0 1rem 1.5rem;
|
margin: 0 0 1rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-captcha-group {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-captcha-group + .btn {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cookie consent banner */
|
/* Cookie consent banner */
|
||||||
.cookie-consent-banner {
|
.cookie-consent-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var configEl = document.getElementById('webmcp-config');
|
||||||
|
if (!configEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelContext = navigator.modelContext || document.modelContext;
|
||||||
|
if (!modelContext || typeof modelContext.registerTool !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var contactUrl = configEl.dataset.contactUrl || '/contact';
|
||||||
|
var pageName = configEl.dataset.pageName || '';
|
||||||
|
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
|
||||||
|
var services = [];
|
||||||
|
var pages = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
services = JSON.parse(configEl.dataset.services || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
services = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
pages = JSON.parse(configEl.dataset.pages || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
pages = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResult(payload) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
var match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageUrl(slug) {
|
||||||
|
var page = pages[slug];
|
||||||
|
if (page && page.url) {
|
||||||
|
return page.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = services.find(function (entry) {
|
||||||
|
return entry.slug === slug;
|
||||||
|
});
|
||||||
|
return service ? service.url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPage(query) {
|
||||||
|
var normalized = String(query || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = Object.keys(pages);
|
||||||
|
for (var i = 0; i < keys.length; i += 1) {
|
||||||
|
var slug = keys[i];
|
||||||
|
var page = pages[slug];
|
||||||
|
if (
|
||||||
|
slug.toLowerCase() === normalized ||
|
||||||
|
page.name.toLowerCase() === normalized ||
|
||||||
|
page.name.toLowerCase().indexOf(normalized) !== -1
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
slug: slug,
|
||||||
|
name: page.name,
|
||||||
|
summary: page.summary,
|
||||||
|
url: page.url || resolvePageUrl(slug),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecaptchaToken() {
|
||||||
|
if (!recaptchaRequired) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.grecaptcha || typeof window.grecaptcha.execute !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'reCAPTCHA is required in production. Use the HTML contact form in a browser session, ' +
|
||||||
|
'or enable DEBUG for audit demos without captcha.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget = document.querySelector('.g-recaptcha');
|
||||||
|
var siteKey = widget ? widget.getAttribute('data-sitekey') : null;
|
||||||
|
if (!siteKey) {
|
||||||
|
var script = document.querySelector('script[src*="recaptcha"]');
|
||||||
|
if (script && script.src) {
|
||||||
|
var keyMatch = script.src.match(/[?&]render=([^&]+)/);
|
||||||
|
siteKey = keyMatch ? decodeURIComponent(keyMatch[1]) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteKey) {
|
||||||
|
throw new Error('reCAPTCHA site key not found on the contact page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.grecaptcha.execute(siteKey, { action: 'contact' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitContactInquiry(input) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('name', input.name);
|
||||||
|
formData.append('email', input.email);
|
||||||
|
formData.append('subject', input.subject);
|
||||||
|
formData.append('message', input.message || '');
|
||||||
|
formData.append('csrfmiddlewaretoken', getCsrfToken());
|
||||||
|
|
||||||
|
if (recaptchaRequired) {
|
||||||
|
var token = await getRecaptchaToken();
|
||||||
|
formData.append('g-recaptcha-response', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await fetch(contactUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = await response.text();
|
||||||
|
if (html.indexOf("We'll be in contact shortly!") !== -1) {
|
||||||
|
return textResult({
|
||||||
|
success: true,
|
||||||
|
message: 'Contact inquiry submitted successfully.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.indexOf('There was an error submitting') !== -1) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Submission failed validation (often reCAPTCHA in production).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.indexOf('is required') !== -1) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required fields: name, email, and subject are required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Unexpected response from contact endpoint.',
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'list_services',
|
||||||
|
description: 'List AI ML Operations service pages with name, URL, and summary.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function () {
|
||||||
|
var results = services.map(function (service) {
|
||||||
|
return {
|
||||||
|
name: service.name,
|
||||||
|
slug: service.slug,
|
||||||
|
url: service.url || resolvePageUrl(service.slug),
|
||||||
|
summary: service.summary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return textResult({ services: results });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'get_page_content',
|
||||||
|
description: 'Look up a public page by slug or service name and return its URL and summary.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Page slug (e.g. forward_deployed) or service name (e.g. AI Agents).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var page = findPage(input.query);
|
||||||
|
if (!page) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'No page found for query: ' + input.query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return textResult({ success: true, page: page });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'navigate_to_service',
|
||||||
|
description: 'Resolve a service slug or name to its canonical marketing page URL.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
service: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Service slug or display name.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['service'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var page = findPage(input.service);
|
||||||
|
if (!page) {
|
||||||
|
return textResult({
|
||||||
|
success: false,
|
||||||
|
error: 'Unknown service: ' + input.service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return textResult({
|
||||||
|
success: true,
|
||||||
|
url: page.url,
|
||||||
|
name: page.name,
|
||||||
|
summary: page.summary,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'open_contact_with_subject',
|
||||||
|
description: 'Build a contact page URL with a pre-filled subject for hosting, pricing, or service inquiries.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
subject: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Subject line to pre-fill on the contact form.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['subject'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var url = new URL(contactUrl, window.location.origin);
|
||||||
|
url.searchParams.set('subject', input.subject);
|
||||||
|
return textResult({
|
||||||
|
success: true,
|
||||||
|
url: url.toString(),
|
||||||
|
subject: input.subject,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pageName === 'contact') {
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'submit_contact_inquiry',
|
||||||
|
description: 'Submit a contact inquiry to AI ML Operations. Requires name, email, and subject.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Sender full name.' },
|
||||||
|
email: { type: 'string', description: 'Sender email address.' },
|
||||||
|
subject: { type: 'string', description: 'Inquiry subject line.' },
|
||||||
|
message: { type: 'string', description: 'Optional message body.' },
|
||||||
|
},
|
||||||
|
required: ['name', 'email', 'subject'],
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: false },
|
||||||
|
execute: submitContactInquiry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -44,6 +44,16 @@
|
|||||||
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
||||||
hidden></div>
|
hidden></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if webmcp_enabled %}
|
||||||
|
<div id="webmcp-config"
|
||||||
|
data-page-name="{{ webmcp_page_name }}"
|
||||||
|
data-contact-url="{{ webmcp_contact_url }}"
|
||||||
|
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
|
||||||
|
data-services='{{ webmcp_services_json|escapejs }}'
|
||||||
|
data-pages='{{ webmcp_pages_json|escapejs }}'
|
||||||
|
hidden></div>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -151,6 +161,10 @@
|
|||||||
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
|
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if webmcp_enabled %}
|
||||||
|
<script src="{% static 'public/js/webmcp-tools.js' %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const mobileBtn = document.querySelector('.mobile-menu-btn');
|
const mobileBtn = document.querySelector('.mobile-menu-btn');
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% include "django_recaptcha/includes/js_v3.html" %}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="{{ widget.name }}"
|
||||||
|
class="g-recaptcha"
|
||||||
|
form="contact-form"
|
||||||
|
{% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
|
||||||
|
>
|
||||||
@@ -33,40 +33,53 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
|
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
|
||||||
<form action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading">
|
<form id="contact-form" action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"
|
||||||
|
toolname="submit_contact_inquiry"
|
||||||
|
tooldescription="Submit a contact inquiry to AI ML Operations">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-name">Your Name</label>
|
<label class="form-label" for="contact-name">Your Name</label>
|
||||||
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required>
|
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required
|
||||||
|
toolparamdescription="Full name of the person submitting the inquiry.">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-email">Your Email</label>
|
<label class="form-label" for="contact-email">Your Email</label>
|
||||||
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required>
|
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required
|
||||||
|
toolparamdescription="Email address where AI ML Operations can reply.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-subject">Subject</label>
|
<label class="form-label" for="contact-subject">Subject</label>
|
||||||
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required>
|
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required
|
||||||
|
toolparamdescription="Short summary of the inquiry topic or service of interest.">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-message">Message</label>
|
<label class="form-label" for="contact-message">Message</label>
|
||||||
<textarea name="message" class="form-control" id="contact-message" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea>
|
<textarea name="message" class="form-control" id="contact-message" rows="5"
|
||||||
</div>
|
placeholder="What workflow is costing you the most time? What systems does it touch?"
|
||||||
|
toolparamdescription="Optional details about the workflow, bottleneck, or systems involved."></textarea>
|
||||||
<div class="form-group">
|
|
||||||
{% if capchaForm %}
|
|
||||||
{{ capchaForm }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not capchaForm %}
|
||||||
<button class="btn" type="submit"
|
<button class="btn" type="submit"
|
||||||
data-tianji-event="contact_form_submit">
|
data-tianji-event="contact_form_submit">
|
||||||
Send Message
|
Send Message
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if capchaForm %}
|
||||||
|
<div class="form-group contact-captcha-group">
|
||||||
|
{{ capchaForm }}
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit" form="contact-form"
|
||||||
|
data-tianji-event="contact_form_submit">
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from django.test import Client, TestCase, override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import Contact
|
from .models import Contact
|
||||||
|
from .seo import SERVICE_URL_NAMES, get_service_entries
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
@@ -170,3 +171,74 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertContains(response, 'id="services-menu-button"')
|
self.assertContains(response, 'id="services-menu-button"')
|
||||||
self.assertContains(response, 'aria-haspopup="true"')
|
self.assertContains(response, 'aria-haspopup="true"')
|
||||||
self.assertContains(response, 'aria-label="Main navigation"')
|
self.assertContains(response, 'aria-label="Main navigation"')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True, WEBMCP_ENABLED=True)
|
||||||
|
class WebMcpTests(TestCase):
|
||||||
|
def test_homepage_includes_webmcp_when_enabled(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "webmcp-config")
|
||||||
|
self.assertContains(response, "webmcp-tools.js")
|
||||||
|
self.assertContains(response, 'data-page-name="public_index"')
|
||||||
|
self.assertContains(response, "forward_deployed")
|
||||||
|
|
||||||
|
@override_settings(WEBMCP_ENABLED=False)
|
||||||
|
def test_homepage_omits_webmcp_when_disabled(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, "webmcp-config")
|
||||||
|
self.assertNotContains(response, "webmcp-tools.js")
|
||||||
|
|
||||||
|
def test_contact_page_includes_webmcp_form_annotations(self):
|
||||||
|
response = self.client.get(reverse("contact"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'id="contact-form"')
|
||||||
|
self.assertContains(response, 'toolname="submit_contact_inquiry"')
|
||||||
|
self.assertContains(response, "tooldescription=")
|
||||||
|
self.assertContains(response, "toolparamdescription=")
|
||||||
|
self.assertContains(response, "submit_contact_inquiry")
|
||||||
|
|
||||||
|
@override_settings(DEBUG=False)
|
||||||
|
def test_contact_page_renders_captcha_outside_annotated_form(self):
|
||||||
|
response = self.client.get(reverse("contact"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
form_end = response.content.index(b"</form>")
|
||||||
|
captcha_index = response.content.index(b"g-recaptcha")
|
||||||
|
self.assertGreater(captcha_index, form_end)
|
||||||
|
self.assertContains(response, b'form="contact-form"')
|
||||||
|
|
||||||
|
def test_webmcp_services_json_includes_all_service_pages(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
for slug in SERVICE_URL_NAMES:
|
||||||
|
self.assertContains(response, slug)
|
||||||
|
|
||||||
|
def test_service_entries_match_public_seo_config(self):
|
||||||
|
entries = get_service_entries()
|
||||||
|
self.assertEqual(len(entries), len(SERVICE_URL_NAMES))
|
||||||
|
self.assertEqual({entry["slug"] for entry in entries}, SERVICE_URL_NAMES)
|
||||||
|
|
||||||
|
def test_webmcp_static_script_defines_expected_tools(self):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
script_path = Path(__file__).resolve().parent / "static/public/js/webmcp-tools.js"
|
||||||
|
script = script_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for tool_name in (
|
||||||
|
"list_services",
|
||||||
|
"get_page_content",
|
||||||
|
"navigate_to_service",
|
||||||
|
"open_contact_with_subject",
|
||||||
|
"submit_contact_inquiry",
|
||||||
|
):
|
||||||
|
self.assertIn("name: '" + tool_name + "'", script)
|
||||||
|
|
||||||
|
self.assertIn("readOnlyHint: true", script)
|
||||||
|
self.assertIn("readOnlyHint: false", script)
|
||||||
|
self.assertIn("navigator.modelContext || document.modelContext", script)
|
||||||
|
|||||||
Reference in New Issue
Block a user