diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 3210f44..23c3f5c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -12,6 +12,6 @@ import { ProxyModule } from '../proxy/proxy.module'; imports: [ProxyModule], controllers: [AuthController], providers: [OAuthService, DiscoveryService], - exports: [OAuthService], + exports: [OAuthService, DiscoveryService], }) export class AuthModule {} diff --git a/src/auth/services/discovery.service.ts b/src/auth/services/discovery.service.ts index a80dfcd..61d6345 100644 --- a/src/auth/services/discovery.service.ts +++ b/src/auth/services/discovery.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { buildSubdomainUrl } from '../../common/utils/url.utils'; /** * OAuth 2.1 Discovery Service @@ -85,4 +86,37 @@ export class DiscoveryService { op_tos_uri: `${baseUrl}/terms`, }; } + + getSubdomainResourceMetadata(alias: string): any { + const baseUrl = this.configService.get('BASE_URL', 'http://localhost:3001'); + const subdomainUrl = buildSubdomainUrl(baseUrl, alias); + + this.logger.log(`Generating subdomain resource metadata for alias: ${alias}`); + + return { + resource: subdomainUrl, + authorization_servers: [subdomainUrl], + bearer_methods_supported: ['header'], + scopes_supported: ['mcp.tools.read', 'mcp.tools.write'], + }; + } + + getSubdomainAuthServerMetadata(alias: string): any { + const baseUrl = this.configService.get('BASE_URL', 'http://localhost:3001'); + const subdomainUrl = buildSubdomainUrl(baseUrl, alias); + + this.logger.log(`Generating subdomain auth server metadata for alias: ${alias}`); + + return { + issuer: subdomainUrl, + authorization_endpoint: `${subdomainUrl}/authorize`, + token_endpoint: `${subdomainUrl}/token`, + registration_endpoint: `${subdomainUrl}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['mcp.tools.read', 'mcp.tools.write'], + }; + } } diff --git a/src/auth/templates/login-page.template.ts b/src/auth/templates/login-page.template.ts index e3f21ed..629c9a2 100644 --- a/src/auth/templates/login-page.template.ts +++ b/src/auth/templates/login-page.template.ts @@ -22,6 +22,7 @@ export function generateLoginPage( }, meta?: AliasMeta, error?: string, + loginActionUrl?: string, ): string { const title = meta?.loginTitle ?? meta?.brandName ?? 'IoT Cloud'; const logo = meta?.loginLogo ?? '🔐'; @@ -130,7 +131,7 @@ export function generateLoginPage( ${error ? `
${error}
` : ''} -
+
{ + const apiKey = await this.aliasService.resolveAlias(alias); + if (!apiKey) { + throw new NotFoundException(`Unknown alias '${alias}'`); + } + return apiKey; + } + + @Get('.well-known/oauth-protected-resource') + async getResourceMetadata(@Param('alias') alias: string): Promise { + await this.resolveOrFail(alias); + return this.discoveryService.getSubdomainResourceMetadata(alias); + } + + @Get('.well-known/oauth-authorization-server') + async getAuthServerMetadata(@Param('alias') alias: string): Promise { + await this.resolveOrFail(alias); + return this.discoveryService.getSubdomainAuthServerMetadata(alias); + } + + @Get('authorize') + async authorize( + @Param('alias') alias: string, + @Query() query: AuthorizeQueryDto, + @Res() res: Response, + ): Promise { + this.logger.log(`Subdomain authorize for alias: ${alias}`); + + const projectApiKey = await this.resolveOrFail(alias); + const meta = await this.partnerMetaService.getAliasMeta(alias); + + const html = generateLoginPage(alias, query, meta ?? undefined, undefined, '/login'); + res.status(HttpStatus.OK).contentType('text/html').send(html); + } + + @Post('login') + async login( + @Param('alias') alias: string, + @Body() + body: { + email: string; + password: string; + client_id: string; + redirect_uri: string; + state: string; + code_challenge: string; + code_challenge_method: string; + scope?: string; + resource?: string; + }, + @Res() res: Response, + ): Promise { + this.logger.log(`Subdomain login for alias: ${alias}`); + + const projectApiKey = await this.resolveOrFail(alias); + const meta = await this.partnerMetaService.getAliasMeta(alias); + + try { + const authCode = await this.oauthService.handleLogin( + projectApiKey, + body.email, + body.password, + body.code_challenge, + body.code_challenge_method, + body.redirect_uri, + body.state, + body.scope, + body.resource, + ); + + const redirectUrl = new URL(body.redirect_uri); + redirectUrl.searchParams.set('code', authCode); + redirectUrl.searchParams.set('state', body.state); + + this.logger.log(`Login successful, redirecting to ${redirectUrl.toString()}`); + res.redirect(HttpStatus.FOUND, redirectUrl.toString()); + } catch (err) { + this.logger.warn(`Login failed for ${body.email}: ${err.message}`); + + const oauthParams = { + client_id: body.client_id, + redirect_uri: body.redirect_uri, + state: body.state, + code_challenge: body.code_challenge, + code_challenge_method: body.code_challenge_method, + scope: body.scope, + response_type: 'code' as const, + resource: body.resource, + }; + + const html = generateLoginPage( + alias, + oauthParams, + meta ?? undefined, + err.message || 'Login failed', + '/login', + ); + res.status(HttpStatus.UNAUTHORIZED).contentType('text/html').send(html); + } + } + + @Options('token') + tokenOptions(@Res() res: Response): void { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.header( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, Accept, x-admin-api-key, x-project-api-key, mcp-protocol-version', + ); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Max-Age', '86400'); + res.status(HttpStatus.NO_CONTENT).send(); + } + + @Post('token') + async token( + @Param('alias') alias: string, + @Body() body: TokenRequestDto, + @Headers() headers: Record, + ): Promise { + this.logger.log(`Subdomain token request for alias: ${alias}, grant_type: ${body.grant_type}`); + + const projectApiKey = await this.resolveOrFail(alias); + + let clientId: string | undefined; + const authHeader = headers.authorization || headers.Authorization; + if (authHeader && authHeader.startsWith('Basic ')) { + try { + const credentials = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8'); + const [id] = credentials.split(':'); + clientId = id; + this.logger.debug(`Basic Auth parsed: client_id=${clientId}`); + } catch (error) { + this.logger.warn(`Failed to parse Basic Auth header: ${error.message}`); + } + } + + if (body.grant_type === 'authorization_code') { + if (!body.code) { + throw new BadRequestException('code is required for authorization_code grant'); + } + return this.oauthService.exchangeCode( + projectApiKey, + body.code, + body.code_verifier, + body.redirect_uri, + body.resource, + ); + } + + if (body.grant_type === 'refresh_token') { + if (!body.refresh_token) { + throw new BadRequestException('refresh_token is required for refresh_token grant'); + } + return this.oauthService.refreshToken(projectApiKey, body.refresh_token, body.resource); + } + + throw new BadRequestException('Unsupported grant_type'); + } + + @Post('register') + async register(@Param('alias') alias: string): Promise { + this.logger.log(`Subdomain client registration for alias: ${alias}`); + await this.resolveOrFail(alias); + + return { + client_id: 'web-client-static', + client_id_issued_at: Math.floor(Date.now() / 1000), + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + } +} diff --git a/src/mcp/mcp.controller.ts b/src/mcp/mcp.controller.ts index b26914f..64bb7ac 100644 --- a/src/mcp/mcp.controller.ts +++ b/src/mcp/mcp.controller.ts @@ -17,6 +17,7 @@ import { randomUUID } from 'crypto'; import { SessionManagerService } from './services/session-manager.service'; import { McpServerFactory } from './services/mcp-server.factory'; import { decodeJwt } from '../common/utils/jwt.utils'; +import { buildSubdomainUrl } from '../common/utils/url.utils'; import { ConfigService } from '@nestjs/config'; import { AliasService } from '../alias/alias.service'; import { PartnerMetaService } from '../alias/partner-meta.service'; @@ -86,9 +87,10 @@ export class McpController { if (!authorization || !authorization.startsWith('Bearer ')) { this.logger.warn(`Missing or invalid Authorization header for alias: ${alias}`); const baseUrl = this.configService.get('BASE_URL', 'http://localhost:3001'); + const subdomainUrl = buildSubdomainUrl(baseUrl, alias); res.setHeader( 'WWW-Authenticate', - `Bearer realm="MCP Gateway", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp/${alias}"`, + `Bearer realm="MCP Gateway", resource_metadata="${subdomainUrl}/.well-known/oauth-protected-resource"`, ); res.status(HttpStatus.UNAUTHORIZED).json({ jsonrpc: '2.0', @@ -119,9 +121,10 @@ export class McpController { } catch (error) { this.logger.error(`JWT decode failed for alias ${alias}: ${error.message}`); const baseUrl = this.configService.get('BASE_URL', 'http://localhost:3001'); + const subdomainUrl = buildSubdomainUrl(baseUrl, alias); res.setHeader( 'WWW-Authenticate', - `Bearer realm="MCP Gateway", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp/${alias}"`, + `Bearer realm="MCP Gateway", resource_metadata="${subdomainUrl}/.well-known/oauth-protected-resource"`, ); res.status(HttpStatus.UNAUTHORIZED).json({ jsonrpc: '2.0', diff --git a/src/mcp/mcp.module.ts b/src/mcp/mcp.module.ts index 0ac1241..7c16bea 100644 --- a/src/mcp/mcp.module.ts +++ b/src/mcp/mcp.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { McpController } from './mcp.controller'; +import { McpAuthController } from './mcp-auth.controller'; import { SessionManagerService } from './services/session-manager.service'; import { McpServerFactory } from './services/mcp-server.factory'; import { RedisSessionRepository } from './services/redis-session.repository'; @@ -27,7 +28,7 @@ import { CommonModule } from '../common/common.module'; AuthModule, // For JWT validation CommonModule, // For shared utilities and decorators ], - controllers: [McpController], + controllers: [McpAuthController, McpController], providers: [SessionManagerService, McpServerFactory, RedisSessionRepository], exports: [SessionManagerService, McpServerFactory, RedisSessionRepository], }) diff --git a/src/tools/definitions/control-device-simple.tool.ts b/src/tools/definitions/control-device-simple.tool.ts index af567e9..1713a3f 100644 --- a/src/tools/definitions/control-device-simple.tool.ts +++ b/src/tools/definitions/control-device-simple.tool.ts @@ -13,13 +13,13 @@ const ControlDeviceSimpleParamsSchema = z.object({ action: z .enum(['turn_on', 'turn_off', 'set_brightness', 'set_kelvin', 'set_temperature', 'set_mode']) .describe( - 'Action to perform. Options: turn_on, turn_off, set_brightness (0-1000), set_kelvin (0-65000), set_temperature (15-30°C), set_mode (0=AUTO, 1=COOLING, 2=DRY, 3=HEATING, 4=FAN)', + 'Action to perform. Options: turn_on, turn_off, set_brightness (0-100%), set_kelvin (0-65000K), set_temperature (15-30°C), set_mode (0=AUTO, 1=COOL, 2=DRY, 3=HEAT, 4=FAN)', ), value: z .number() .nullish() .describe( - 'Value for set_* actions. Required for set_brightness, set_kelvin, set_temperature, set_mode. Not used for turn_on/turn_off', + 'Value for set_* actions. Ranges: set_brightness 0-100 (percent), set_kelvin 0-65000, set_temperature 15-30, set_mode 0-4. Not used for turn_on/turn_off.', ), elementId: z .number() @@ -38,7 +38,7 @@ export type ControlDeviceSimpleParams = z.infer; + +export const INTERACTIVE_DEVICE_TOOL = { + name: 'interactive_device', + description: + 'Open an interactive control panel widget for a device. ' + + 'Use this when the user wants to control or manage a device without specifying exact actions ' + + '(e.g. "control the light", "adjust the AC", "manage bedroom lamp"). ' + + 'Shows a visual UI with power toggle, brightness slider, temperature controls, mode selector, etc. ' + + 'Do NOT use if the user specifies an exact command — use control_device_simple instead ' + + '(e.g. "turn off the light" or "set brightness to 80").', + inputSchema: { + type: 'object' as const, + properties: { + uuid: { + type: 'string', + description: 'Device UUID', + }, + }, + required: ['uuid'], + }, + metadata: { + name: 'interactive_device', + description: + 'Open an interactive control panel widget for a device. ' + + 'Use when user wants to control a device without specifying exact actions. ' + + 'Shows visual UI with power, brightness, temperature, and mode controls.', + readOnlyHint: true, + securitySchemes: { + oauth2: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'mcp.tools.read': 'Read access to MCP tools', + }, + }, + }, + }, + }, + }, + schema: InteractiveDeviceParamsSchema, + _meta: { + ui: { + resourceUri: 'ui://widget/device-app.html', + visibility: ['model', 'app'], + }, + 'ui/resourceUri': 'ui://widget/device-app.html', + 'openai/outputTemplate': 'ui://widget/device-app.html', + 'openai/widgetAccessible': true, + 'openai/resultCanProduceWidget': true, + 'openai/toolInvocation/invoking': 'Opening device controls...', + 'openai/toolInvocation/invoked': 'Device controls ready', + 'openai/widgetDescription': + 'Interactive device control panel is displayed as a widget. Do not describe it in text.', + }, +}; diff --git a/src/tools/definitions/list-devices.tool.ts b/src/tools/definitions/list-devices.tool.ts index e12c62f..fc241db 100644 --- a/src/tools/definitions/list-devices.tool.ts +++ b/src/tools/definitions/list-devices.tool.ts @@ -56,6 +56,7 @@ export const LIST_DEVICES_TOOL = { resourceUri: 'ui://widget/device-app.html', visibility: ['model', 'app'], }, + 'ui/resourceUri': 'ui://widget/device-app.html', 'openai/outputTemplate': 'ui://widget/device-app.html', 'openai/widgetAccessible': true, 'openai/resultCanProduceWidget': true, diff --git a/src/tools/services/tool-executor.service.ts b/src/tools/services/tool-executor.service.ts index 4f7163f..b4d09b4 100644 --- a/src/tools/services/tool-executor.service.ts +++ b/src/tools/services/tool-executor.service.ts @@ -45,6 +45,10 @@ import { WIDGET_CONTROL_DEVICE_TOOL, WidgetControlDeviceParams, } from '../definitions/widget-control-device.tool'; +import { + INTERACTIVE_DEVICE_TOOL, + InteractiveDeviceParams, +} from '../definitions/interactive-device.tool'; import { sanitizeErrorForClient } from '../../common/utils/error.utils'; /** Context for tool execution containing request metadata */ @@ -98,6 +102,8 @@ export class ToolExecutorService { this.executeWidgetGetDevice(p as WidgetGetDeviceParams, c), [WIDGET_CONTROL_DEVICE_TOOL.name]: (p, c) => this.executeWidgetControlDevice(p as WidgetControlDeviceParams, c), + [INTERACTIVE_DEVICE_TOOL.name]: (p, c) => + this.executeWidgetControlDevice(p as InteractiveDeviceParams, c), }; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -128,6 +134,19 @@ export class ToolExecutorService { }; } + private requireValue(value: number | null | undefined, action: string): number { + if (value == null) { + throw new BadRequestException(`value is required for ${action} action`); + } + return value; + } + + private validateRange(value: number, min: number, max: number, label: string): void { + if (value < min || value > max) { + throw new BadRequestException(`${label} must be ${min}-${max}. Got: ${value}`); + } + } + /** Wrap error as MCP CallToolResult with sanitized message */ private errorResult(error: unknown, includeAuthHint = true): CallToolResult { const errorMessage = sanitizeErrorForClient(error); @@ -606,32 +625,36 @@ export class ToolExecutorService { case 'turn_off': command = [1, 0]; break; - case 'set_brightness': - if (params.value == null) { - throw new Error('value is required for set_brightness action'); - } - command = [28, params.value]; + case 'set_brightness': { + const v = this.requireValue(params.value, 'set_brightness'); + this.validateRange(v, 0, 100, 'Brightness (percent)'); + command = [28, Math.round(v * 10)]; break; - case 'set_kelvin': - if (params.value == null) { - throw new Error('value is required for set_kelvin action'); - } - command = [29, params.value]; + } + case 'set_kelvin': { + const v = this.requireValue(params.value, 'set_kelvin'); + this.validateRange(v, 0, 65000, 'Color temperature (Kelvin)'); + command = [29, Math.round(v)]; break; - case 'set_temperature': - if (params.value == null) { - throw new Error('value is required for set_temperature action'); - } - command = [20, params.value]; + } + case 'set_temperature': { + const v = this.requireValue(params.value, 'set_temperature'); + this.validateRange(v, 15, 30, 'Temperature (°C)'); + command = [20, Math.round(v)]; break; - case 'set_mode': - if (params.value == null) { - throw new Error('value is required for set_mode action'); + } + case 'set_mode': { + const v = this.requireValue(params.value, 'set_mode'); + if (!Number.isInteger(v) || v < 0 || v > 4) { + throw new BadRequestException( + `Invalid mode: ${v}. Valid modes: 0=AUTO, 1=COOL, 2=DRY, 3=HEAT, 4=FAN`, + ); } - command = [17, params.value]; + command = [17, v]; break; + } default: - throw new Error(`Unknown action: ${params.action}`); + throw new BadRequestException(`Unknown action: ${params.action}`); } // Use specified elementId or all device elementIds diff --git a/src/tools/services/tool-registry.service.ts b/src/tools/services/tool-registry.service.ts index 54c542c..5e69b9a 100644 --- a/src/tools/services/tool-registry.service.ts +++ b/src/tools/services/tool-registry.service.ts @@ -24,6 +24,7 @@ import { CONTROL_DEVICE_SIMPLE_TOOL } from '../definitions/control-device-simple import { WIDGET_LIST_DEVICES_TOOL } from '../definitions/widget-list-devices.tool'; import { WIDGET_GET_DEVICE_TOOL } from '../definitions/widget-get-device.tool'; import { WIDGET_CONTROL_DEVICE_TOOL } from '../definitions/widget-control-device.tool'; +import { INTERACTIVE_DEVICE_TOOL } from '../definitions/interactive-device.tool'; /** All tool definitions in registration order */ const ALL_TOOL_DEFINITIONS = [ @@ -44,6 +45,7 @@ const ALL_TOOL_DEFINITIONS = [ WIDGET_LIST_DEVICES_TOOL, WIDGET_GET_DEVICE_TOOL, WIDGET_CONTROL_DEVICE_TOOL, + INTERACTIVE_DEVICE_TOOL, ] as const; /** diff --git a/views/widgets/device-app.html b/views/widgets/device-app.html index 5ebcb35..c7ba0ca 100644 --- a/views/widgets/device-app.html +++ b/views/widgets/device-app.html @@ -1180,6 +1180,108 @@ var _currentData = null; var _listPage = 0; var LIST_PAGE_SIZE = 10; + /* ── Cross-client MCP Apps Bridge ────────────────── */ + var _bridge = (function () { + var _id = 0; + var _pending = {}; + var _handlers = {}; + var _hostCtx = null; + var _connected = false; + + window.addEventListener('message', function (event) { + var msg = event.data; + if (!msg || typeof msg !== 'object') return; + if (msg.jsonrpc === '2.0' && msg.id != null && _pending[msg.id]) { + var cb = _pending[msg.id]; + delete _pending[msg.id]; + if (msg.error) cb.reject(msg.error); + else cb.resolve(msg.result); + return; + } + if (msg.jsonrpc === '2.0' && msg.method) { + var p = msg.params || {}; + if (msg.method === 'ui/notifications/tool-result') { + var d = + p.structuredContent || + (p.content && p.content[0] && p.content[0].text + ? (function () { + try { + return JSON.parse(p.content[0].text); + } catch (e) { + return null; + } + })() + : null); + if (d && _handlers.ontoolresult) _handlers.ontoolresult(d); + } else if (msg.method === 'ui/notifications/host-context-changed') { + _hostCtx = p; + if (_handlers.onhostcontextchanged) _handlers.onhostcontextchanged(p); + } + return; + } + if (msg.type === 'tool-result') { + var d2 = msg.structuredContent || msg; + if (_handlers.ontoolresult) _handlers.ontoolresult(d2); + } + }); + + function _send(method, params) { + var id = ++_id; + return new Promise(function (resolve, reject) { + _pending[id] = { resolve: resolve, reject: reject }; + window.parent.postMessage( + { jsonrpc: '2.0', method: method, params: params || {}, id: id }, + '*', + ); + }); + } + + return { + on: function (evt, fn) { + _handlers[evt] = fn; + }, + hostContext: function () { + return _hostCtx; + }, + isConnected: function () { + return _connected; + }, + + connect: function () { + if (window === window.parent) { + return Promise.resolve(null); + } + return _send('ui/initialize', { + appName: 'device-app', + appVersion: '1.0.0', + capabilities: {}, + }) + .then(function (result) { + _connected = true; + _hostCtx = result && result.hostContext ? result.hostContext : {}; + window.parent.postMessage( + { jsonrpc: '2.0', method: 'ui/notifications/initialized' }, + '*', + ); + return _hostCtx; + }) + .catch(function () { + return null; + }); + }, + + callServerTool: function (name, args) { + if (!_connected && window.openai && typeof window.openai.callTool === 'function') { + return window.openai.callTool(name, args).then(function (r) { + return r && r.structuredContent ? r.structuredContent : r; + }); + } + return _send('tools/call', { name: name, arguments: args || {} }).then(function (r) { + return r && r.structuredContent ? r.structuredContent : r; + }); + }, + }; + })(); /* Timeout to show empty state if no data arrives within 8s */ var _initTimer = setTimeout(function () { if (_currentView === null) renderView('list', { _view: 'list', total: 0, devices: [] }); @@ -1213,77 +1315,54 @@ function goBack() { if (_history.length === 0) return; var prev = _history.pop(); - if (!window.openai || typeof window.openai.callTool !== 'function') { - renderView(prev.view, prev.data); - return; - } - /* Refetch fresh data so state changes from control actions are reflected */ if (prev.view === 'dashboard' && prev.data && prev.data.uuid) { showNavLoading(); - var p1 = window.openai.callTool('_widget_get_device', { uuid: prev.data.uuid }); - if (p1 && typeof p1.then === 'function') { - p1.then(function (result) { + _bridge + .callServerTool('_widget_get_device', { uuid: prev.data.uuid }) + .then(function (data) { hideNavLoading(); - var fresh = result && result.structuredContent ? result.structuredContent : result; - renderView('dashboard', fresh); - }).catch(function () { + renderView('dashboard', data); + }) + .catch(function () { hideNavLoading(); renderView(prev.view, prev.data); }); - } else { - hideNavLoading(); - renderView(prev.view, prev.data); - } } else if (prev.view === 'list') { showNavLoading(); - var p2 = window.openai.callTool('_widget_list_devices', {}); - if (p2 && typeof p2.then === 'function') { - p2.then(function (result) { + _bridge + .callServerTool('_widget_list_devices', {}) + .then(function (data) { hideNavLoading(); - var fresh = result && result.structuredContent ? result.structuredContent : result; - renderView('list', fresh); - }).catch(function () { + renderView('list', data); + }) + .catch(function () { hideNavLoading(); renderView(prev.view, prev.data); }); - } else { - hideNavLoading(); - renderView(prev.view, prev.data); - } } else if (prev.view === 'location' && prev.data && prev.data.locationId) { showNavLoading(); - var p3 = window.openai.callTool('_widget_list_devices', { - locationId: prev.data.locationId, - }); - if (p3 && typeof p3.then === 'function') { - p3.then(function (result) { + _bridge + .callServerTool('_widget_list_devices', { locationId: prev.data.locationId }) + .then(function (data) { hideNavLoading(); - var fresh = result && result.structuredContent ? result.structuredContent : result; - renderView('location', fresh); - }).catch(function () { + renderView('location', data); + }) + .catch(function () { hideNavLoading(); renderView(prev.view, prev.data); }); - } else { - hideNavLoading(); - renderView(prev.view, prev.data); - } } else if (prev.view === 'group' && prev.data && prev.data.groupId) { showNavLoading(); - var p4 = window.openai.callTool('_widget_list_devices', { groupId: prev.data.groupId }); - if (p4 && typeof p4.then === 'function') { - p4.then(function (result) { + _bridge + .callServerTool('_widget_list_devices', { groupId: prev.data.groupId }) + .then(function (data) { hideNavLoading(); - var fresh = result && result.structuredContent ? result.structuredContent : result; - renderView('group', fresh); - }).catch(function () { + renderView('group', data); + }) + .catch(function () { hideNavLoading(); renderView(prev.view, prev.data); }); - } else { - hideNavLoading(); - renderView(prev.view, prev.data); - } } else { renderView(prev.view, prev.data); } @@ -1300,22 +1379,17 @@ } function callToolAndNavigate(tool, args, targetView) { - if (window.openai && typeof window.openai.callTool === 'function') { - showNavLoading(); - window.openai - .callTool(tool, args) - .then(function (result) { - hideNavLoading(); - var data = result && result.structuredContent ? result.structuredContent : result; - navigate(targetView, data); - }) - .catch(function (err) { - hideNavLoading(); - console.error('[DDW] callTool failed', err); - }); - } else { - console.log('[DDW] [Preview] callTool', tool, args, '->', targetView); - } + showNavLoading(); + _bridge + .callServerTool(tool, args) + .then(function (data) { + hideNavLoading(); + navigate(targetView, data); + }) + .catch(function (err) { + hideNavLoading(); + console.error('[DDW] callServerTool failed', err); + }); } function getBackButtonHtml() { @@ -1354,17 +1428,12 @@ if (nav) nav.classList.remove('ddw-dark'); } } + _bridge.on('onhostcontextchanged', function (ctx) { + if (ctx && ctx.theme) applyTheme(ctx.theme); + }); if (window.openai && window.openai.theme) { applyTheme(window.openai.theme); } - window.addEventListener( - 'openai:set_globals', - function (event) { - var globals = event.detail && event.detail.globals; - if (globals && globals.theme) applyTheme(globals.theme); - }, - { passive: true }, - ); /* ── Device Icons ──────────────────────────────────── */ var DEVICE_ICONS = { @@ -2322,55 +2391,28 @@ var args = { uuid: uuid, action: action, elementId: elementId }; if (value != null) args.value = value; - if (window.openai && typeof window.openai.callTool === 'function') { - try { - var p = window.openai.callTool('control_device_simple', args); - if (p && typeof p.then === 'function') { - p.then(function () { - if (rowEl) rowEl.classList.remove('ddw-sending'); - }).catch(function () { - if (rowEl) rowEl.classList.remove('ddw-sending'); - }); - } else { - setTimeout(function () { - if (rowEl) rowEl.classList.remove('ddw-sending'); - }, 1500); - } - } catch (e) { + _bridge + .callServerTool('control_device_simple', args) + .then(function () { if (rowEl) rowEl.classList.remove('ddw-sending'); - } - } else { - setTimeout(function () { + }) + .catch(function () { if (rowEl) rowEl.classList.remove('ddw-sending'); - }, 800); - } + }); } function sendRawControl(uuid, elementId, command, rowEl) { if (rowEl) rowEl.classList.add('ddw-sending'); var args = { uuid: uuid, elementIds: [elementId], command: command }; - if (window.openai && typeof window.openai.callTool === 'function') { - try { - var p = window.openai.callTool('control_device', args); - if (p && typeof p.then === 'function') { - p.then(function () { - if (rowEl) rowEl.classList.remove('ddw-sending'); - }).catch(function () { - if (rowEl) rowEl.classList.remove('ddw-sending'); - }); - } else { - setTimeout(function () { - if (rowEl) rowEl.classList.remove('ddw-sending'); - }, 1500); - } - } catch (e) { + + _bridge + .callServerTool('control_device', args) + .then(function () { if (rowEl) rowEl.classList.remove('ddw-sending'); - } - } else { - setTimeout(function () { + }) + .catch(function () { if (rowEl) rowEl.classList.remove('ddw-sending'); - }, 800); - } + }); } var _sliderTimers = {}; @@ -2592,51 +2634,30 @@ ); /* ── Initialization & Data Bridge ──────────────────── */ - if (window.openai && window.openai.toolOutput) { - renderView(null, window.openai.toolOutput); - } + _bridge.on('ontoolresult', function (data) { + renderView(null, data); + }); - window.addEventListener( - 'message', - function (event) { - if (event.source !== window.parent) return; - var msg = event.data; - if (!msg || typeof msg !== 'object') return; - if (msg.jsonrpc === '2.0' && msg.method === 'ui/notifications/tool-result') { - var sc = msg.params && msg.params.structuredContent; - if (sc) { - renderView(null, sc); - } else { - /* Fallback: try parsing content[0].text as JSON (non-ChatGPT clients) */ - var contentArr = msg.params && msg.params.content; - var textItem = null; - if (Array.isArray(contentArr)) { - for (var i = 0; i < contentArr.length; i++) { - if (contentArr[i] && contentArr[i].type === 'text') { - textItem = contentArr[i]; - break; - } - } - } - if (textItem && textItem.text) { - try { - renderView(null, JSON.parse(textItem.text)); - } catch (e) {} - } - } - } - if (msg.type === 'tool-result' && msg.structuredContent) { - renderView(null, msg.structuredContent); - } - }, - { passive: true }, - ); + _bridge.connect().then(function (ctx) { + if (ctx && ctx.theme) applyTheme(ctx.theme); + if (ctx && ctx.locale) { + document.documentElement.lang = ctx.locale; + } + }); + + /* Legacy ChatGPT: read toolOutput if bridge didn't connect */ + setTimeout(function () { + if (!_bridge.isConnected() && window.openai && window.openai.toolOutput) { + renderView(null, window.openai.toolOutput); + } + }, 200); window.addEventListener( 'openai:set_globals', function (event) { var globals = event.detail && event.detail.globals; if (globals && globals.toolOutput) renderView(null, globals.toolOutput); + if (globals && globals.theme) applyTheme(globals.theme); }, { passive: true }, );