From c5c3ec0b2ce5631349b5fea0fbfb2101f260c833 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 9 Oct 2026 18:34:29 +0800 Subject: [PATCH] feat(models): add workspace starred model and processor defaults --- skills/skills/langbot-mcp-ops/SKILL.md | 11 +++ .../http/controller/groups/provider/models.py | 20 ++++++ src/langbot/pkg/api/http/service/agent.py | 7 ++ src/langbot/pkg/api/http/service/model.py | 68 +++++++++++++++++++ src/langbot/pkg/api/http/service/pipeline.py | 3 + src/langbot/pkg/api/mcp/server.py | 16 +++++ .../agents/components/AgentCreateContent.tsx | 11 +++ .../LangBotModelMetadata.tsx | 6 +- .../models-dialog/components/ModelItem.tsx | 8 ++- .../models-dialog/components/ModelStar.tsx | 48 +++++++++++++ .../settings-dialog/SettingsDialog.tsx | 6 +- web/src/app/infra/http/BackendClient.ts | 12 ++++ web/src/i18n/locales/en-US.ts | 3 + web/src/i18n/locales/es-ES.ts | 3 + web/src/i18n/locales/ja-JP.ts | 3 + web/src/i18n/locales/ru-RU.ts | 3 + web/src/i18n/locales/th-TH.ts | 3 + web/src/i18n/locales/vi-VN.ts | 3 + web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + 20 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 web/src/app/home/components/models-dialog/components/ModelStar.tsx diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 2647573b4..976dabffe 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -193,3 +193,14 @@ Monitoring record filters accept `mode` (`all`, `real`, `debug`) and `execution_statuses` (normalized execution statuses). These select the owning execution, not the individual model/tool call outcome. Calls without a recorded execution link are excluded when an execution filter is active. + +### Workspace default LLM model + +`get_starred_model` reads the Workspace-wide favorite. `set_starred_model` +requires `provider_secret.manage` and replaces the single starred model; pass +`null` to clear it. The model must belong to the current Workspace. +`get_default_model` resolves the favorite first, then the Space wizard chat +recommendation only when LangBot Models is enabled and the Workspace owner is +bound to a LangBot Account. It returns a nullable `uuid`. +New processor LLM selector defaults use this preference; existing configurations +are not rewritten. Embedding and rerank models are not eligible. diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py index 32a15e947..678550b69 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -14,6 +14,26 @@ MODEL_TEST_ERROR_CODE = 'model_test_failed' @group.group_class('models/llm', '/api/v1/provider/models/llm') class LLMModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: + @self.route('/_/starred', methods=['GET'], permission=Permission.RESOURCE_VIEW) + async def get_starred(request_context: RequestContext) -> str: + return self.success(data={'uuid': await self.ap.llm_model_service.get_starred_model(request_context)}) + + @self.route('/_/starred', methods=['PUT'], permission=Permission.PROVIDER_SECRET_MANAGE) + async def set_starred(request_context: RequestContext) -> str: + body = await quart.request.json + model_uuid = body.get('uuid') + if model_uuid is not None and not isinstance(model_uuid, str): + return self.http_status(400, -1, 'uuid must be a string or null') + try: + await self.ap.llm_model_service.set_starred_model(request_context, model_uuid) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data={}) + + @self.route('/_/default', methods=['GET'], permission=Permission.RESOURCE_MANAGE) + async def get_default(request_context: RequestContext) -> str: + return self.success(data={'uuid': await self.ap.llm_model_service.get_default_model(request_context)}) + @self.route( '', methods=['GET'], diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index d6a75983c..2d626117a 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -449,6 +449,11 @@ class AgentService: config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config(context) config, runner_id, _ = RunnerConfigResolver.resolve_agent_config(config) await self._validate_runner_for_agent(context, runner_id) + if runner_id: + descriptor = await self.ap.runner_registry.get(context, runner_id) + config['runner_config'][runner_id] = await self.ap.llm_model_service.apply_default_model( + context, descriptor.config_schema, config['runner_config'][runner_id], + ) patterns = agent_data.get('supported_event_patterns', AGENT_DEFAULT_EVENT_PATTERNS) new_uuid = str(uuid.uuid4()) values = { @@ -560,6 +565,8 @@ class AgentService: parameters = runner_config.get(component_ref) if parameters is None: parameters = self.ap.pipeline_service._get_default_values_from_schema(descriptor.config_schema) + if draft: + parameters = await self.ap.llm_model_service.apply_default_model(context, descriptor.config_schema, parameters) if not isinstance(parameters, dict): raise ValueError('Processor parameters must be an object') for field in descriptor.config_schema: diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index bf8c6b196..bbf992974 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -196,6 +196,74 @@ class LLMModelsService: def __init__(self, ap: app.Application) -> None: self.ap = ap + async def get_starred_model(self, context: TenantContext) -> str | None: + from ....entity.persistence.metadata import WorkspaceMetadata + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(WorkspaceMetadata.value).where( + WorkspaceMetadata.workspace_uuid == require_workspace_uuid(context), + WorkspaceMetadata.key == 'starred_llm_model', + ) + ) + row = result.first() + model_uuid = row[0] if row else None + if model_uuid and await self.get_llm_model(context, model_uuid) is not None: + return model_uuid + return None + + async def set_starred_model(self, context: TenantContext, model_uuid: str | None) -> None: + from ....entity.persistence.metadata import WorkspaceMetadata + workspace_uuid = require_workspace_uuid(context) + if model_uuid and await self.get_llm_model(context, model_uuid) is None: + raise ValueError('Model not found in this Workspace') + # A single metadata row makes concurrent selections replace each other. + dialect = self.ap.persistence_mgr.get_db_engine().dialect.name + if dialect == 'postgresql': + from sqlalchemy.dialects.postgresql import insert + else: + from sqlalchemy.dialects.sqlite import insert + statement = insert(WorkspaceMetadata).values( + workspace_uuid=workspace_uuid, key='starred_llm_model', value=model_uuid or '', + ) + await self.ap.persistence_mgr.execute_async(statement.on_conflict_do_update( + index_elements=['workspace_uuid', 'key'], set_={'value': model_uuid or ''}, + )) + + async def get_default_model(self, context: TenantContext) -> str | None: + starred = await self.get_starred_model(context) + if starred: + return starred + if self.ap.instance_config.data.get('space', {}).get('disable_models_service', False): + return None + owner = await self.ap.user_service.get_workspace_owner(require_workspace_uuid(context)) + cloud = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + if not cloud and not (owner and owner.space_account_uuid): + return None + try: + return (await self.ap.space_service.get_recommended_chat_model(context))['uuid'] + except Exception: + return None + + async def apply_default_model(self, context: TenantContext, schema: list[dict], parameters: dict) -> dict: + import copy + fields = [field for field in schema if field.get('type') in ( + 'llm-model-selector', 'select-llm-model', 'model-fallback-selector', + ) and field.get('name')] + if not fields: + return parameters + model_uuid = await self.get_default_model(context) + if not model_uuid: + return parameters + result = copy.deepcopy(parameters) + for field in fields: + name = field['name'] + if field['type'] == 'model-fallback-selector': + value = result.get(name) + value = value if isinstance(value, dict) else {} + if not value.get('primary'): + result[name] = {**value, 'primary': model_uuid, 'fallbacks': value.get('fallbacks', [])} + elif not result.get(name): + result[name] = model_uuid + return result async def get_llm_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]: """Get all LLM models with provider info""" result = await self.ap.persistence_mgr.execute_async( diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 54568433c..15ef3408b 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -68,6 +68,9 @@ class PipelineService: runner_config['id'] = selected.id runner_config.setdefault('expire-time', 0) ai_config['runner_config'] = {selected.id: self._get_default_values_from_schema(selected.config_schema)} + ai_config['runner_config'][selected.id] = await self.ap.llm_model_service.apply_default_model( + context, selected.config_schema, ai_config['runner_config'][selected.id], + ) return config async def get_pipeline_metadata(self, context: TenantContext) -> list[dict]: diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 104532f5d..6c2367a37 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -424,6 +424,22 @@ class LangBotMCPServer: context = _authorized(Permission.RUNTIME_OPERATE) return _dump(await ap.agent_service.debug_agent(context, processor_uuid, payload)) + @mcp.tool(description='Get the single starred LLM model UUID for the current Workspace.') + async def get_starred_model() -> str: + context = _authorized(Permission.RESOURCE_VIEW) + return _dump({'uuid': await ap.llm_model_service.get_starred_model(context)}) + + @mcp.tool(description='Star one LLM model for this Workspace, replacing the previous choice. Pass null to clear.') + async def set_starred_model(model_uuid: str | None = None) -> str: + context = _authorized(Permission.PROVIDER_SECRET_MANAGE) + await ap.llm_model_service.set_starred_model(context, model_uuid) + return _dump({'uuid': model_uuid}) + + @mcp.tool(description='Resolve the default LLM model: starred model, then the available Space wizard recommendation.') + async def get_default_model() -> str: + context = _authorized(Permission.RESOURCE_MANAGE) + return _dump({'uuid': await ap.llm_model_service.get_default_model(context)}) + @mcp.tool(description='List all configured LLM models. Secrets are redacted.') async def list_llm_models() -> str: context = _authorized(Permission.RESOURCE_VIEW) diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx index 68d375bf4..57cbb062c 100644 --- a/web/src/app/home/agents/components/AgentCreateContent.tsx +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -103,6 +103,17 @@ export default function AgentCreateContent({ const parameters = runnerStage ? getDefaultValues(runnerStage.config) : {}; const config = { runner: { id: runner }, runner_config: { [runner]: parameters } }; try { + if (kind === 'pipeline' && runnerStage?.config.some((field) => ['llm-model-selector', 'select-llm-model', 'model-fallback-selector'].includes(field.type))) { + const { uuid } = await httpClient.getDefaultModel(); + if (uuid) { + for (const field of runnerStage.config) { + if (['llm-model-selector', 'select-llm-model'].includes(field.type) && !parameters[field.name]) parameters[field.name] = uuid; + if (field.type === 'model-fallback-selector' && !parameters[field.name]?.primary) { + parameters[field.name] = { ...parameters[field.name], primary: uuid, fallbacks: parameters[field.name]?.fallbacks ?? [] }; + } + } + } + } if (kind === 'pipeline') { // Keep the created ID if configuration fails, so retry does not create duplicates. const uuid = createdPipeline.current ?? (await httpClient.createAgent({ kind, name: values.name, description: '', emoji: values.emoji })).uuid; diff --git a/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx b/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx index bd5d58c16..e44e5b83c 100644 --- a/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx +++ b/web/src/app/home/components/model-availability/LangBotModelMetadata.tsx @@ -47,7 +47,7 @@ export default function LangBotModelMetadata({ event.preventDefault()} > @@ -59,12 +59,12 @@ export default function LangBotModelMetadata({

{t('models.pricing.title')}

-

+

{t('models.pricing.input', { credits: input, })}

-

+

{t('models.pricing.output', { credits: output, })} diff --git a/web/src/app/home/components/models-dialog/components/ModelItem.tsx b/web/src/app/home/components/models-dialog/components/ModelItem.tsx index c8c25bde1..2223d3308 100644 --- a/web/src/app/home/components/models-dialog/components/ModelItem.tsx +++ b/web/src/app/home/components/models-dialog/components/ModelItem.tsx @@ -25,6 +25,7 @@ import { TestResult, } from '../types'; import ExtraArgsEditor from './ExtraArgsEditor'; +import ModelStar from './ModelStar'; import { userInfo } from '@/app/infra/http'; import LangBotModelMetadata from '../../model-availability/LangBotModelMetadata'; @@ -197,14 +198,14 @@ export default function ModelItem({ >

-
- {model.name} +
+ {model.name} {modelType === 'llm' ? t('models.chat') @@ -240,6 +241,7 @@ export default function ModelItem({ loaded={availabilityLoaded} /> )} + {modelType === 'llm' && } {canManage && !isLangBotModels && ( (); +const listeners = new Set<() => void>(); +const pending = new Map>(); +const empty = { uuid: null, busy: false, loaded: false }; +const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; +function update(workspace: string, value: typeof empty | { uuid: string | null; busy: boolean; loaded: boolean }) { + records.set(workspace, value); + listeners.forEach((listener) => listener()); +} + +export default function ModelStar({ uuid, disabled }: { uuid: string; disabled: boolean }) { + const workspace = useCurrentWorkspace()?.workspace.uuid ?? ''; + const { t } = useTranslation(); + const state = useSyncExternalStore(subscribe, () => records.get(workspace) ?? empty); + useEffect(() => { + if (!workspace || pending.has(workspace)) return; + const request = backendClient.getStarredModel().then((result) => { + update(workspace, { uuid: result.uuid, busy: false, loaded: true }); + }).catch(() => { update(workspace, { uuid: null, busy: false, loaded: false }); }) + .finally(() => pending.delete(workspace)); + pending.set(workspace, request); + }, [workspace]); + const starred = state.uuid === uuid; + return ; +} diff --git a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx index b992f0634..e642337fc 100644 --- a/web/src/app/home/components/settings-dialog/SettingsDialog.tsx +++ b/web/src/app/home/components/settings-dialog/SettingsDialog.tsx @@ -191,7 +191,7 @@ export default function SettingsDialog({ }} > @@ -202,7 +202,7 @@ export default function SettingsDialog({ {/* Override the SidebarProvider wrapper's default h-svh so the two columns fill the dialog's fixed height instead of the viewport. */} - + -
+
{/* Mobile section switcher (sidebar is hidden on small screens) */}
{navItems.map((item) => ( diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index b8591c4c4..4202daac1 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -372,6 +372,18 @@ export class BackendClient extends BaseHttpClient { return this.post('/api/v1/agents', agent); } + public getStarredModel(): Promise<{ uuid: string | null }> { + return this.get('/api/v1/provider/models/llm/_/starred'); + } + + public setStarredModel(uuid: string | null): Promise { + return this.put('/api/v1/provider/models/llm/_/starred', { uuid }); + } + + public getDefaultModel(): Promise<{ uuid: string | null }> { + return this.get('/api/v1/provider/models/llm/_/default'); + } + public updateAgent(uuid: string, agent: Partial): Promise { return this.put(`/api/v1/agents/${uuid}`, agent); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index a043df2c8..3496bf74f 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -538,6 +538,9 @@ const enUS = { help: 'Get Help', }, models: { + starModel: 'Star model (default for new processors)', + unstarModel: 'Unstar model', + starModelFailed: 'Could not update starred model', codex: { account: 'ChatGPT subscription', description: diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 47ff3cf8c..86a1104cc 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -545,6 +545,9 @@ const esES = { help: 'Obtener ayuda', }, models: { + starModel: 'Marcar como favorito para nuevos procesadores', + unstarModel: 'Quitar de favoritos', + starModelFailed: 'No se pudo actualizar el modelo favorito', codex: { account: 'Suscripción de ChatGPT', description: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d1d1dbeba..c1750b2a1 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -539,6 +539,9 @@ const jaJP = { help: 'ヘルプドキュメントを見る', }, models: { + starModel: '新規処理器の既定モデルに設定', + unstarModel: 'スターを解除', + starModelFailed: 'スター付きモデルを更新できませんでした', codex: { account: 'ChatGPT サブスクリプション', description: diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index a4ed2e381..f29c0dc01 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -541,6 +541,9 @@ const ruRU = { help: 'Помощь', }, models: { + starModel: 'Отметить модель для новых обработчиков', + unstarModel: 'Снять отметку', + starModelFailed: 'Не удалось изменить избранную модель', codex: { account: 'Подписка ChatGPT', description: diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index cba92792a..9c50d267b 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -529,6 +529,9 @@ const thTH = { help: 'ขอความช่วยเหลือ', }, models: { + starModel: 'ติดดาวเพื่อใช้เป็นค่าเริ่มต้นสำหรับตัวประมวลผลใหม่', + unstarModel: 'ยกเลิกดาว', + starModelFailed: 'อัปเดตโมเดลที่ติดดาวไม่สำเร็จ', codex: { account: 'การสมัครสมาชิก ChatGPT', description: diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 850774666..312f56e96 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -535,6 +535,9 @@ const viVN = { help: 'Trợ giúp', }, models: { + starModel: 'Đánh dấu làm mô hình mặc định cho bộ xử lý mới', + unstarModel: 'Bỏ đánh dấu', + starModelFailed: 'Không thể cập nhật mô hình được đánh dấu', codex: { account: 'Gói đăng ký ChatGPT', description: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 26851b95b..0e3bb1b20 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -505,6 +505,9 @@ const zhHans = { help: '查看帮助文档', }, models: { + starModel: '标星模型(用作新处理器的默认模型)', + unstarModel: '取消标星', + starModelFailed: '无法更新标星模型', codex: { account: 'ChatGPT 订阅', description: diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 79638f23d..0383156c9 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -502,6 +502,9 @@ const zhHant = { help: '查看說明文件', }, models: { + starModel: '標星模型(用作新處理器的預設模型)', + unstarModel: '取消標星', + starModelFailed: '無法更新標星模型', codex: { account: 'ChatGPT 訂閱', description: