feat(models): add workspace starred model and processor defaults

This commit is contained in:
Hyu
2026-10-09 18:34:29 +08:00
parent 4c70977a1d
commit c5c3ec0b2c
20 changed files with 231 additions and 9 deletions
+11
View File
@@ -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.
@@ -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'],
@@ -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:
+68
View File
@@ -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(
@@ -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]:
+16
View File
@@ -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)
@@ -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;
@@ -47,7 +47,7 @@ export default function LangBotModelMetadata({
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex items-center gap-1 text-xs tabular-nums text-muted-foreground"
className="inline-flex items-center gap-1 whitespace-nowrap text-xs tabular-nums text-muted-foreground"
onMouseDown={(event) => event.preventDefault()}
>
<Coins className="size-3" />
@@ -59,12 +59,12 @@ export default function LangBotModelMetadata({
<TooltipContent side="top" className="max-w-64">
<div className="space-y-0.5">
<p>{t('models.pricing.title')}</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs">
{t('models.pricing.input', {
credits: input,
})}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs">
{t('models.pricing.output', {
credits: output,
})}
@@ -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({
>
<PopoverTrigger asChild>
<div
className={`flex items-center justify-between py-2 px-3 rounded-md border bg-background ${
className={`flex items-center gap-3 py-2 px-3 rounded-md border bg-background ${
isPopoverDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:bg-accent cursor-pointer'
}`}
>
<div className="flex min-w-0 items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
<div className="flex min-w-0 flex-1 items-center gap-2 flex-wrap">
<span className="break-all text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-xs">
{modelType === 'llm'
? t('models.chat')
@@ -240,6 +241,7 @@ export default function ModelItem({
loaded={availabilityLoaded}
/>
)}
{modelType === 'llm' && <ModelStar uuid={model.uuid} disabled={!canManage || isPopoverDisabled} />}
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
@@ -0,0 +1,48 @@
import { useEffect, useSyncExternalStore } from 'react';
import { Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
const records = new Map<string, { uuid: string | null; busy: boolean; loaded: boolean }>();
const listeners = new Set<() => void>();
const pending = new Map<string, Promise<void>>();
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 <Button type="button" variant="ghost" size="icon" className={`size-7 shrink-0 ${starred ? 'text-amber-500 hover:text-amber-600' : 'text-muted-foreground'}`}
disabled={disabled || state.busy || !state.loaded}
aria-pressed={starred} aria-label={t(starred ? 'models.unstarModel' : 'models.starModel')}
title={t(starred ? 'models.unstarModel' : 'models.starModel')}
onPointerDown={(event) => event.stopPropagation()}
onClick={async (event) => {
event.stopPropagation();
const next = starred ? null : uuid;
update(workspace, { ...state, busy: true });
try {
await backendClient.setStarredModel(next);
update(workspace, { uuid: next, busy: false, loaded: true });
} catch {
update(workspace, { ...state, busy: false });
toast.error(t('models.starModelFailed'));
}
}}><Star className={`size-3.5 ${starred ? 'fill-current' : ''}`} /></Button>;
}
@@ -191,7 +191,7 @@ export default function SettingsDialog({
}}
>
<DialogContent
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
className="flex h-[80dvh] max-h-[800px] min-h-0 flex-col gap-0 overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
// Fixed height so switching sections never resizes the dialog; each
// panel scrolls its own content internally.
>
@@ -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. */}
<SidebarProvider className="!min-h-0 h-full">
<SidebarProvider className="!min-h-0 flex-1 overflow-hidden">
<Sidebar
collapsible="none"
className="hidden h-full w-44 shrink-0 border-r md:flex"
@@ -231,7 +231,7 @@ export default function SettingsDialog({
</SidebarContent>
</Sidebar>
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{/* Mobile section switcher (sidebar is hidden on small screens) */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
{navItems.map((item) => (
+12
View File
@@ -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<object> {
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<Agent>): Promise<object> {
return this.put(`/api/v1/agents/${uuid}`, agent);
}
+3
View File
@@ -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:
+3
View File
@@ -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:
+3
View File
@@ -539,6 +539,9 @@ const jaJP = {
help: 'ヘルプドキュメントを見る',
},
models: {
starModel: '新規処理器の既定モデルに設定',
unstarModel: 'スターを解除',
starModelFailed: 'スター付きモデルを更新できませんでした',
codex: {
account: 'ChatGPT サブスクリプション',
description:
+3
View File
@@ -541,6 +541,9 @@ const ruRU = {
help: 'Помощь',
},
models: {
starModel: 'Отметить модель для новых обработчиков',
unstarModel: 'Снять отметку',
starModelFailed: 'Не удалось изменить избранную модель',
codex: {
account: 'Подписка ChatGPT',
description:
+3
View File
@@ -529,6 +529,9 @@ const thTH = {
help: 'ขอความช่วยเหลือ',
},
models: {
starModel: 'ติดดาวเพื่อใช้เป็นค่าเริ่มต้นสำหรับตัวประมวลผลใหม่',
unstarModel: 'ยกเลิกดาว',
starModelFailed: 'อัปเดตโมเดลที่ติดดาวไม่สำเร็จ',
codex: {
account: 'การสมัครสมาชิก ChatGPT',
description:
+3
View File
@@ -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:
+3
View File
@@ -505,6 +505,9 @@ const zhHans = {
help: '查看帮助文档',
},
models: {
starModel: '标星模型(用作新处理器的默认模型)',
unstarModel: '取消标星',
starModelFailed: '无法更新标星模型',
codex: {
account: 'ChatGPT 订阅',
description:
+3
View File
@@ -502,6 +502,9 @@ const zhHant = {
help: '查看說明文件',
},
models: {
starModel: '標星模型(用作新處理器的預設模型)',
unstarModel: '取消標星',
starModelFailed: '無法更新標星模型',
codex: {
account: 'ChatGPT 訂閱',
description: