mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
feat(models): add workspace starred model and processor defaults
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -539,6 +539,9 @@ const jaJP = {
|
||||
help: 'ヘルプドキュメントを見る',
|
||||
},
|
||||
models: {
|
||||
starModel: '新規処理器の既定モデルに設定',
|
||||
unstarModel: 'スターを解除',
|
||||
starModelFailed: 'スター付きモデルを更新できませんでした',
|
||||
codex: {
|
||||
account: 'ChatGPT サブスクリプション',
|
||||
description:
|
||||
|
||||
@@ -541,6 +541,9 @@ const ruRU = {
|
||||
help: 'Помощь',
|
||||
},
|
||||
models: {
|
||||
starModel: 'Отметить модель для новых обработчиков',
|
||||
unstarModel: 'Снять отметку',
|
||||
starModelFailed: 'Не удалось изменить избранную модель',
|
||||
codex: {
|
||||
account: 'Подписка ChatGPT',
|
||||
description:
|
||||
|
||||
@@ -529,6 +529,9 @@ const thTH = {
|
||||
help: 'ขอความช่วยเหลือ',
|
||||
},
|
||||
models: {
|
||||
starModel: 'ติดดาวเพื่อใช้เป็นค่าเริ่มต้นสำหรับตัวประมวลผลใหม่',
|
||||
unstarModel: 'ยกเลิกดาว',
|
||||
starModelFailed: 'อัปเดตโมเดลที่ติดดาวไม่สำเร็จ',
|
||||
codex: {
|
||||
account: 'การสมัครสมาชิก ChatGPT',
|
||||
description:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -505,6 +505,9 @@ const zhHans = {
|
||||
help: '查看帮助文档',
|
||||
},
|
||||
models: {
|
||||
starModel: '标星模型(用作新处理器的默认模型)',
|
||||
unstarModel: '取消标星',
|
||||
starModelFailed: '无法更新标星模型',
|
||||
codex: {
|
||||
account: 'ChatGPT 订阅',
|
||||
description:
|
||||
|
||||
@@ -502,6 +502,9 @@ const zhHant = {
|
||||
help: '查看說明文件',
|
||||
},
|
||||
models: {
|
||||
starModel: '標星模型(用作新處理器的預設模型)',
|
||||
unstarModel: '取消標星',
|
||||
starModelFailed: '無法更新標星模型',
|
||||
codex: {
|
||||
account: 'ChatGPT 訂閱',
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user