diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3284b8a..207c438b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,8 @@ that issue instead of expanding the PR. Continue with independent issues. - **Execution client**: the product registers only `connector_type=agents_api` and uses `packages/agents-client/v1` with the official OpenAI Go SDK. Core owns daemon connections, engine selection and environments. + Product image builds include `contracts/agents-api/v1` for shared public + configuration validation, alongside the client package. ## Architecture boundaries @@ -2372,7 +2374,8 @@ raw tool Items, general web-search control or text-verbosity levels. Router admi for `environment:none` uses the available engine capability, not an engine name. The independent API selects new Session engines through `AGENTS_API_ENGINE` (`codex` by default, `claude_sdk` or `mcode`); existing Sessions keep their stored engine. -This is operator configuration, not a public harness-selector field. API admission, +This remains the deployment default; the optional Core harness extension selects +an enabled engine for one saved or inline Agent configuration. API admission, device selection and the final preclaim check share the execution service's narrow engine policy without importing native adapters. Selected engines require the common durable execution capabilities. Codex retains its general search/verbosity checks; @@ -2564,8 +2567,9 @@ or filesystem isolation. Automatic installation remains separate. - Configure product-to-Core access with `PARSAR_CORE_WORKSPACES_FILE`: each workspace maps to a distinct Core project and caller key file. See [product deployment](docs/deploy/product-core.md) for the complete configuration. - Provider credentials belong in the independent Core service. An unconfigured - product can start; Core operations return an explicit unavailable error. + Workspace model Provider credentials belong to the Parsar catalog and are sent + through the write-only Core Session execution extension. Operator defaults remain + an independent Core deployment option. An unconfigured product can start; Core operations return an explicit unavailable error. - Root Compose deploys the product and its database. It mounts `PARSAR_CORE_CONFIG_DIR` read-only for workspace bindings and caller key files. The installer creates an empty binding list without overwriting existing @@ -2629,12 +2633,84 @@ or filesystem isolation. Automatic installation remains separate. four platforms. Companion installation does not grant API authorization; task-scoped uploads keep the current run requester and workspace checks. +### Product model catalog and execution credentials + +Parsar owns workspace Model Providers, their write-only API keys, model catalog, +Agent model selection and business permissions. Reuse the existing `models` table +with workspace-owned Provider references; do not reactivate legacy product model +calls, probes or runtime adapters. Core has no product Provider CRUD or catalog. +Build → Models groups model rows under Providers using the Runs ledger pattern, +with Provider settings in the detail rail; Credentials remains for +third-party platform credentials. Members may read safe catalog metadata; +only workspace owners/admins may mutate it. Audit mutations without keys or input +payloads. Product keys require `PARSAR_MASTER_KEY` and authenticated workspace and +resource identity within the existing encrypted envelope. + +An Agent saves `config.model_id` plus the exact resolved `model` string. The UI +selects from its workspace catalog and validates protocol/Harness compatibility. +Catalog identity and token limits are immutable; rename changes the display label +only. Anthropic Messages supports Claude Code and MiniMax Code; Responses supports +Codex. MiniMax Code requires explicit context/output limits. These checks establish +configuration compatibility, not availability of an arbitrary upstream model. + +Before the first Core request, atomically freeze model identity and Provider settings +under the catalog row locks. Encrypt the private Provider snapshot separately from +ordinary `product_core_sessions.request`, bound to workspace and binding ID. Recover +an existing binding before consulting mutable catalog rows. Provider edits/deletion +and Agent edits apply to new Sessions only. Existing Sessions retain their model, +endpoint and key; key rotation does not rewrite them. A deleted selection fails new +execution explicitly. Omitted catalog selection on older Agents preserves their +existing operator-configured execution behavior. + +Only Session creation carries the typed write-only execution configuration; the +product connector adds it after loading its durable snapshot. Core encrypts it in +the Session transaction using the existing credential cipher and tenant/Session +binding. Creation idempotency includes the confidential intent by hash. The native +adapters consume this snapshot without operator credential fallback. Missing keys, +decryption failure or incompatible configuration fail closed. Public resources, +events and ordinary configuration must not expose the key. Current support requires +a qualified hosted environment. See [Session model execution](contracts/agents-api/model-execution.md). + +### Harness selection and Agent defaults + +Core accepts the optional `agent.x_agents_core.harness` extension through the +saved Agent and inline Session configuration paths. Define the extension once in +`contracts/agents-api/v1`; never use metadata or a competing top-level selector. +Resolve saved overrides before selecting the existing Session engine, and apply +that engine's execution policy before persistence. Omitted selection preserves +the deployment default; explicit unavailable selection fails without fallback. +Effective extension reads use the persisted engine; Sessions without the extension +retain the official Agent response shape. See the [extension contract](contracts/agents-api/harness-selection.md) +for null/retry behavior and operator configuration. + +Hosted engine-to-provider selection belongs to Core composition. Admission and +initial allocation share the mapping; retained allocations use their persisted +provider identity. Runtime images must satisfy their existing qualification rules. +Transient model options are partitioned by engine and must not expose another +engine's credentials. Do not infer an engine from a model name or template. + +Parsar Agents save default model, harness extension and a typed, non-confidential +`config.environment` selector. The Agent form requires an explicit harness before +saving; existing records without one remain unset in read-only views. The Agent +list shows the configured environment type and harness in both table and compact +layouts. The environment selector is product configuration; +it becomes the official Session `environment`, never part of the protocol Agent. +Only a template reference or supported environment selector is stored, not live +container identity or initialization secrets. An explicitly chosen conversation +environment retains its existing override behavior. Otherwise the first message +uses the Agent defaults. Creating an Agent or opening an empty chat performs no +Core Session or container creation. One conversation/Agent binding freezes the +request on first execution; later messages and observer retries reuse it. Separate +conversations get independent Sessions and environments. Edits affect future +Sessions only. Keep the existing product navigation and direct empty-chat composer. + ### Product Core execution - Product Agent configuration uses the complete pinned inline Agent contract: model, instructions (mapped once from the product `system_prompt` field), tools, service_tier, reasoning, text and multi_agent. Do not narrow this schema to the - current Core MVP. Reject old engine/device/provider settings; Core validates + current Core MVP. The explicit Core harness extension and product environment + defaults follow the rules above. Reject old engine/device/provider settings; Core validates its current execution support. Business display/visibility stay in Parsar. A supplied product `config` replaces the execution configuration; omitted config preserves it. The separately supplied SP remains independent of that replacement. @@ -3302,8 +3378,8 @@ wrapped rows grow naturally. Keep this behavior in the shared header, with `actionClassName` reserved for page-specific action arrangements. Agent forms edit the model, instructions and pinned upstream Agent configuration. -Environment templates belong to Core; Session creation chooses a template or -an explicit environment type. Preserve drafts when an upstream operation fails +Environment templates belong to Core; Agent forms save a default template or +explicit environment type for new Sessions. Preserve drafts when an upstream operation fails and show unavailable features without inventing a successful local substitute. New and cloned Agents default invocation scope to workspace. Scope choices explain the Feishu gate without implying anonymous Web/API access. diff --git a/Dockerfile b/Dockerfile index d68d14ec..c582525f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,6 +80,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ COPY internal ./internal COPY catalog ./catalog +COPY contracts/agents-api/v1 ./contracts/agents-api/v1 COPY packages/agents-client ./packages/agents-client COPY server ./server diff --git a/apps/web/src/components/layout/AdminLayout.tsx b/apps/web/src/components/layout/AdminLayout.tsx index 4a930902..51c6b8da 100644 --- a/apps/web/src/components/layout/AdminLayout.tsx +++ b/apps/web/src/components/layout/AdminLayout.tsx @@ -128,6 +128,7 @@ const menuGroups: MenuGroup[] = [ // and the platforms it can be let out on. groupKey: "buildGroup", items: [ + { id: "models", itemKey: "models", icon: Database }, { id: "environments", itemKey: "environments", icon: Database }, { id: "capabilities", itemKey: "capabilities", icon: Wrench }, { id: "connections", itemKey: "connections", icon: Plug }, diff --git a/apps/web/src/components/layout/SessionLauncher.tsx b/apps/web/src/components/layout/SessionLauncher.tsx index 8f69d6e8..a4e2a401 100644 --- a/apps/web/src/components/layout/SessionLauncher.tsx +++ b/apps/web/src/components/layout/SessionLauncher.tsx @@ -1,19 +1,17 @@ -import { useEffect, useState } from "react" +import { useEffect } from "react" import { useWorkspaceId } from "../../lib/workspace" -import { useMyWorkspaces } from "../../lib/api-workspaces" -import { StartSessionDialog } from "../../pages/admin/core/StartSessionDialog" +import { useAdminView } from "../../lib/admin-router" export function SessionLauncher() { const workspaceID = useWorkspaceId() - const workspaces = useMyWorkspaces() - const role = workspaces.data?.workspaces.find(workspace => workspace.id === workspaceID)?.role - const [session, setSession] = useState<{ agentID?: string } | null>(null) + const { navigate } = useAdminView() useEffect(() => { - const start = (event: Event) => setSession({ agentID: (event as CustomEvent).detail }) + const start = (event: Event) => { + if (!workspaceID) return + navigate("conversations", { focus: "compose", agent: (event as CustomEvent).detail }) + } window.addEventListener("core:start-session", start) return () => window.removeEventListener("core:start-session", start) - }, []) - return session && workspaceID && role && role !== "viewer" - ? setSession(null)} /> - : null + }, [workspaceID, navigate]) + return null } diff --git a/apps/web/src/components/ui/ledger.tsx b/apps/web/src/components/ui/ledger.tsx index 37d7db2b..fb0d7f07 100644 --- a/apps/web/src/components/ui/ledger.tsx +++ b/apps/web/src/components/ui/ledger.tsx @@ -255,11 +255,15 @@ export function LedgerGroup({ label, count, defaultOpen = true, + onSelect, + selected, children, }: { label: React.ReactNode count?: number defaultOpen?: boolean + onSelect?: () => void + selected?: boolean children: React.ReactNode }) { const [open, setOpen] = React.useState(defaultOpen) @@ -267,20 +271,25 @@ export function LedgerGroup({
{open &&
    {children}
}
@@ -289,19 +298,20 @@ export function LedgerGroup({ export const LedgerRow = React.forwardRef< HTMLLIElement, - React.LiHTMLAttributes & { selected?: boolean } ->(({ selected, className, children, ...props }, ref) => { + React.LiHTMLAttributes & { selected?: boolean; interactive?: boolean } +>(({ selected, interactive = true, className, children, ...props }, ref) => { const { template, trailingActions, columns } = useColumns() return (
  • *]:min-w-0 [&>*]:self-center", + "group relative grid h-9 min-w-min cursor-default items-center gap-x-2.5 border-b border-line text-sm text-fg outline-none transition-colors duration-150 ease-settle [&>*]:min-w-0 [&>*]:self-center", gutterClass(trailingActions), + interactive && "hover:app-hover focus-visible:app-hover", selected && "app-selected before:absolute before:bottom-0 before:left-0 before:top-0 before:w-0.5 before:bg-accent before:content-['']", className, )} diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index f2d891af..602c8348 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -2921,6 +2921,39 @@ "skillsPending": "Import, edit and version Skill assets in the capability library. Loading them for an Agent requires Core environment initialization support, which is not connected yet. Saving an asset does not activate it for execution.", "memberEnvironment": "Choose an environment when starting a new conversation. Each conversation uses its own Core session and environment; an Agent does not own a permanent container or share a workspace across conversations.", "templateSupport": "Name and enabled/disabled network access are connected. Packages, setup commands, environment variables, restricted network rules and capability installation are waiting for Core support.", - "assetManagementHint": "Manage Parsar capability assets and MCP configuration here. Importing or authorizing an asset does not enable Agent execution. Runtime loading belongs to Core and is not connected yet." + "assetManagementHint": "Manage Parsar capability assets and MCP configuration here. Importing or authorizing an asset does not enable Agent execution. Runtime loading belongs to Core and is not connected yet.", + "executionDefaults": "Default execution configuration", + "selectHarness": "Select a harness", + "harnessNotSet": "Not set", + "harnessHint": "Uses a harness configured in Core. Unavailable harnesses and unsupported model or environment combinations fail explicitly.", + "executionDefaultsHint": "Saving does not start an environment. The first message creates an independent environment, reused by this chat. Changes apply to new chats only." + }, + "catalog": { + "title": "Models", + "model": "Model", + "selectModel": "Select a model", + "legacy": "Select a catalog model (previously {{model}})", + "unavailable": "Saved model is unavailable", + "agentHint": "Choose from Build → Models. Provider models require a hosted environment. Unsupported Harness combinations are disabled.", + "addProvider": "Add Provider", + "editProvider": "Edit Provider", + "addModel": "Add model", + "editModel": "Edit model name", + "providerHint": "Configure one API key per Provider, then add its models. Third-party platform credentials are managed separately in Credentials.", + "modelHint": "The model identifier is sent to Core unchanged. Provider and identifier cannot be changed after creation.", + "protocol": "Protocol", + "modelKey": "Model identifier", + "contextWindow": "Context window (tokens)", + "outputTokens": "Maximum output tokens", + "limitsHint": "Required for MiniMax Code. Enter the limits supported by this model; use 0 if you do not use MiniMax Code. Limits are fixed at creation.", + "keyUnchanged": "Leave blank to keep the current key", + "keyRequired": "Enter API key", + "configured": "Key configured", + "search": "Search Providers or models", + "empty": "No Providers yet", + "noModels": "Add a model under this Provider to use it in an Agent.", + "deleteTitle": "Delete {{name}}?", + "deleteHint": "Existing Sessions keep their frozen configuration. Agents using this model or Provider cannot start new Sessions until another model is selected.", + "incompatible": "Choose a compatible Harness and a hosted environment for this model." } } diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 96dde15e..d11ea3dd 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -2921,6 +2921,39 @@ "skillsPending": "Skills 资产可在能力库中导入、编辑和管理版本。将它们加载给 Agent 运行,需要 Core 的环境初始化支持,目前等待实现。保存资产不表示已在 Agent 中启用。", "memberEnvironment": "发起新对话时选择运行环境。每段对话有独立的 Core 会话和环境;Agent 不永久占用容器,也不默认跨对话共享工作目录。", "templateSupport": "名称和启用/禁用网络已接通。软件包、初始化命令、环境变量、域名限制及能力安装等待 Core 实现。", - "assetManagementHint": "这里管理 Parsar 的能力资产和 MCP 配置。导入或授权成功不代表已可供 Agent 运行;运行时加载由 Core 承载,目前尚未接通。" + "assetManagementHint": "这里管理 Parsar 的能力资产和 MCP 配置。导入或授权成功不代表已可供 Agent 运行;运行时加载由 Core 承载,目前尚未接通。", + "executionDefaults": "默认执行配置", + "selectHarness": "请选择 Harness", + "harnessNotSet": "未设置", + "harnessHint": "使用 Core 已配置的 Harness。未启用或不支持的模型与环境组合会明确报错。", + "executionDefaultsHint": "只保存配置。第一条消息才创建独立环境;同一聊天持续复用。修改仅影响之后的新聊天。" + }, + "catalog": { + "title": "模型", + "model": "模型", + "selectModel": "选择模型", + "legacy": "请选择目录中的模型(原模型:{{model}})", + "unavailable": "已保存的模型不可用", + "agentHint": "从 Build → 模型中选择。Provider 模型需要托管环境;不兼容的 Harness 组合不可选。", + "addProvider": "添加 Provider", + "editProvider": "编辑 Provider", + "addModel": "添加模型", + "editModel": "编辑模型名称", + "providerHint": "在 Provider 上配置 API Key,再添加它提供的模型。第三方平台凭据单独在 Credentials 中管理。", + "modelHint": "模型标识会原样传给 Core。创建后不能修改 Provider 和模型标识。", + "protocol": "协议", + "modelKey": "模型标识", + "contextWindow": "上下文窗口(token)", + "outputTokens": "最大输出 token", + "limitsHint": "MiniMax Code 必填,请填写模型支持的上限;不使用 MiniMax Code 可填 0。创建后上限不可修改。", + "keyUnchanged": "留空保留现有 Key", + "keyRequired": "输入 API Key", + "configured": "已配置 Key", + "search": "搜索 Provider 或模型", + "empty": "暂无 Provider", + "noModels": "在此 Provider 下添加模型后,即可在 Agent 中选择。", + "deleteTitle": "删除 {{name}}?", + "deleteHint": "已有 Session 保留原配置。使用此模型或 Provider 的 Agent 需要选择其他模型后才能启动新 Session。", + "incompatible": "请为此模型选择兼容的 Harness 和托管环境。" } } diff --git a/apps/web/src/lib/admin-router.ts b/apps/web/src/lib/admin-router.ts index 7a154a80..dd81181b 100644 --- a/apps/web/src/lib/admin-router.ts +++ b/apps/web/src/lib/admin-router.ts @@ -8,6 +8,7 @@ export type AdminView = | "approvals" | "agents" | "environments" + | "models" | "capabilities" | "connections" | "members" @@ -27,6 +28,7 @@ const ALL_ADMIN_VIEWS: AdminView[] = [ "approvals", "agents", "environments", + "models", "capabilities", "connections", "members", @@ -110,6 +112,7 @@ export interface NavigateOptions { item?: string | null from?: string | null pendingCapability?: string | null + agent?: string | null focus?: string | null } @@ -172,6 +175,7 @@ export function useNavigateAdmin() { setOptionalParam(url, "item", opts?.item) setOptionalParam(url, "from", opts?.from) setOptionalParam(url, "pendingCapability", opts?.pendingCapability) + setOneShotParam(url, "agent", opts?.agent) setOneShotParam(url, "focus", opts?.focus) window.history.pushState({}, "", url.toString()) window.dispatchEvent(new Event("admin:navigate")) @@ -217,6 +221,7 @@ export function navigateAdmin(next: AdminView, opts?: NavigateOptions) { setOptionalParam(url, "item", opts?.item) setOptionalParam(url, "from", opts?.from) setOptionalParam(url, "pendingCapability", opts?.pendingCapability) + setOneShotParam(url, "agent", opts?.agent) setOneShotParam(url, "focus", opts?.focus) window.history.pushState({}, "", url.toString()) window.dispatchEvent(new Event("admin:navigate")) diff --git a/apps/web/src/lib/core-api.ts b/apps/web/src/lib/core-api.ts index 078bb89d..9783ff82 100644 --- a/apps/web/src/lib/core-api.ts +++ b/apps/web/src/lib/core-api.ts @@ -5,7 +5,11 @@ export type CoreEnvironment = | { type: "openai_hosted"; environment_template_id?: string } | { type: "self_hosted"; workspace_directory: string; capability_directories?: string[] } | { type: "none" } +export type CoreHarness = "codex" | "claude_sdk" | "mcode" export interface CoreAgentConfig { + model_id?: string + x_agents_core?: { harness: CoreHarness } | null + environment?: CoreEnvironment model: string service_tier?: "auto" | "default" | "flex" | "priority" | "fast" | null tools?: Record[] | null @@ -54,3 +58,11 @@ export function jsonObject(text: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a JSON object") return value as Record } + +export function coreExecutionDefaults(config: Record | null | undefined): { harness: CoreHarness | ""; environment: CoreEnvironment } { + const extension = config?.x_agents_core as CoreAgentConfig["x_agents_core"] + return { harness: extension?.harness ?? "", environment: (config?.environment as CoreEnvironment | undefined) ?? { type: "openai_hosted" } } +} +export function coreHarnessLabel(harness: CoreHarness | ""): string { + return { codex: "Codex", claude_sdk: "Claude Code", mcode: "MiniMax Code", "": "" }[harness] +} diff --git a/apps/web/src/lib/model-catalog.ts b/apps/web/src/lib/model-catalog.ts new file mode 100644 index 00000000..c2e04be7 --- /dev/null +++ b/apps/web/src/lib/model-catalog.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query" +import { apiRequest } from "./api-client" +import { coreHarnessLabel, type CoreHarness } from "./core-api" +export interface ModelProvider { id: string; name: string; protocol: "anthropic" | "responses"; base_url: string; key_configured: boolean } +export interface CatalogModel { id: string; name: string; model_key: string; provider_id: string; provider_name: string; protocol: ModelProvider["protocol"]; context_window: number; max_output_tokens: number } +export const catalogKey = (workspace: string | null) => ["model-catalog", workspace] as const +export const catalogPath = (workspace: string, resource: "models" | "model-providers", id?: string) => `/api/v1/workspaces/${encodeURIComponent(workspace)}/${resource}${id ? `/${encodeURIComponent(id)}` : ""}` +export function useModelCatalog(workspace: string | null) { + return useQuery({ queryKey: catalogKey(workspace), enabled: !!workspace, queryFn: async () => { + const [providers, models] = await Promise.all([ + apiRequest<{ providers: ModelProvider[] }>(catalogPath(workspace!, "model-providers")), + apiRequest<{ models: CatalogModel[] }>(catalogPath(workspace!, "models")), + ]) + return { ...providers, ...models } + } }) +} +export function supportsCatalogModel(model: CatalogModel, harness: CoreHarness | ""): boolean { + if (!harness) return true + if (harness === "codex") return model.protocol === "responses" + return model.protocol === "anthropic" && (harness !== "mcode" || (model.context_window > 0 && model.max_output_tokens > 0)) +} + +export function catalogHarnessLabels(model: CatalogModel): string { + return (["codex", "claude_sdk", "mcode"] as const).filter(harness => supportsCatalogModel(model, harness)).map(coreHarnessLabel).join(" · ") +} diff --git a/apps/web/src/lib/use-new-conversation.ts b/apps/web/src/lib/use-new-conversation.ts new file mode 100644 index 00000000..ff755f8a --- /dev/null +++ b/apps/web/src/lib/use-new-conversation.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { useAdminView } from "./admin-router" +import { createConversation, sendUserMessage } from "./api-conversations" +import { writeConversationViewState } from "./conversation-view-state" + +export function useNewConversation(wsId: string | null, selectedAgentId: string, entityId: string | null) { + const qc = useQueryClient() + const { navigate } = useAdminView() + const firstSendPending = useRef | null>(null) + const firstSend = useRef<{ + workspaceId: string + agentId: string + conversationId?: string + } | null>(null) + + useEffect(() => { + firstSendPending.current = null + firstSend.current = null + }, [wsId, selectedAgentId, entityId]) + + // "New conversation" navigates to an empty composer without pre-creating a + // conv — the conv is created on first send via handleSendFromEmpty, + // so the list only shows rows with a real first user turn. + const openCreate = () => { + firstSend.current = null + firstSendPending.current = null + navigate("conversations", { id: "", focus: "compose", agent: selectedAgentId }) + } + + // First-send creates the conv + posts the message + navigates in. + // Title derives from the first 30 chars so the list gets a + // meaningful name immediately (server defaults to "Untitled conversation"). + const handleSendFromEmpty = (content: string): Promise => { + if (firstSendPending.current) return firstSendPending.current + const pending = sendFromEmpty(content).finally(() => { if (firstSendPending.current === pending) firstSendPending.current = null }) + firstSendPending.current = pending + return pending + } + const sendFromEmpty = async (content: string): Promise => { + if (!wsId || !selectedAgentId) { + throw new Error("workspace_id and agent_id required for empty-state send") + } + let attempt = firstSend.current + if (!attempt || attempt.workspaceId !== wsId || attempt.agentId !== selectedAgentId) { + attempt = { workspaceId: wsId, agentId: selectedAgentId } + firstSend.current = attempt + } + let cid = attempt.conversationId + if (!cid) { + const conv = await createConversation(wsId, { + title: content.slice(0, 30), + surface: "web", + form: "thread", + agent_id: selectedAgentId, + }) + cid = conv.id + const createdAttempt = { ...attempt, conversationId: cid } + if (firstSend.current === attempt) firstSend.current = createdAttempt + attempt = createdAttempt + } + try { + await sendUserMessage(cid, { content }) + } finally { + // A failed first message still leaves a real conversation in the list. + qc.invalidateQueries({ + predicate: (q) => + q.queryKey[0] === "admin" && q.queryKey[1] === "conversations" && q.queryKey[2] === wsId, + }) + qc.invalidateQueries({ queryKey: ["admin", "conversationTimeline", cid] }) + } + if (firstSend.current !== attempt) return false + firstSend.current = null + writeConversationViewState(wsId, { + agentId: selectedAgentId, + conversationId: cid, + }) + navigate("conversations", { id: cid }) + return true + } + + return { openCreate, handleSendFromEmpty, resetFirstSend: () => { firstSend.current = null; firstSendPending.current = null } } +} diff --git a/apps/web/src/pages/admin/AdminRouter.tsx b/apps/web/src/pages/admin/AdminRouter.tsx index 8b37e63a..e652e878 100644 --- a/apps/web/src/pages/admin/AdminRouter.tsx +++ b/apps/web/src/pages/admin/AdminRouter.tsx @@ -12,6 +12,7 @@ import { Button } from "../../components/ui/button" import { ConversationsPage } from "./ConversationsPage" import { RunsPage } from "./RunsPage" import { EnvironmentsPage } from "./core/EnvironmentsPage" +import { ModelsPage } from "./models/ModelsPage" import { AgentsPage } from "./AgentsPage" import { SettingsPage } from "./SettingsPage" import { AuditPage } from "./AuditPage" @@ -42,6 +43,7 @@ export function AdminRouter() { if (v === "agents") { return } + if (v === "models") return if (v === "environments") return if (v === "settings") return if (v === "audit") return diff --git a/apps/web/src/pages/admin/ConversationsPage.tsx b/apps/web/src/pages/admin/ConversationsPage.tsx index 5240052a..020940e5 100644 --- a/apps/web/src/pages/admin/ConversationsPage.tsx +++ b/apps/web/src/pages/admin/ConversationsPage.tsx @@ -1,5 +1,5 @@ +import { useNewConversation } from "../../lib/use-new-conversation" import { requestStartSession } from "../../lib/core-api" -import { useQueryClient } from "@tanstack/react-query" import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react" import { useTranslation } from "react-i18next" import * as DropdownMenu from "@radix-ui/react-dropdown-menu" @@ -37,8 +37,6 @@ import { useAdminView } from "../../lib/admin-router" import { ApiError } from "../../lib/api-client" import { useAgents } from "../../lib/api-agents" import { - createConversation, - sendUserMessage, useConversation, useDeleteConversation, useConversations, @@ -120,7 +118,7 @@ export function ConversationsPage() { pickedAgent.workspaceId === wsId && pickedAgent.agentId ? pickedAgent.agentId : savedViewState.agentId - const selectedAgentId = currentConv?.primary_agent_id || pickedAgentId || (allAgents[0]?.id ?? "") + const selectedAgentId = currentConv?.primary_agent_id || (!entityId && new URLSearchParams(window.location.search).get("agent")) || pickedAgentId || (allAgents[0]?.id ?? "") const selectedAgent = allAgents.find((a) => a.id === selectedAgentId) useEffect(() => { @@ -170,69 +168,7 @@ export function ConversationsPage() { }) } - const qc = useQueryClient() - const firstSend = useRef<{ - workspaceId: string - agentId: string - conversationId?: string - } | null>(null) - - useEffect(() => { - firstSend.current = null - }, [wsId, selectedAgentId, entityId]) - - // "New conversation" navigates to an empty composer without pre-creating a - // conv — the conv is created on first send via handleSendFromEmpty, - // so the list only shows rows with a real first user turn. - const openCreate = () => { - firstSend.current = null - navigate("conversations", { id: "", focus: "compose" }) - } - - // First-send creates the conv + posts the message + navigates in. - // Title derives from the first 30 chars so the list gets a - // meaningful name immediately (server defaults to "Untitled conversation"). - const handleSendFromEmpty = async (content: string): Promise => { - if (!wsId || !selectedAgentId) { - throw new Error("workspace_id and agent_id required for empty-state send") - } - let attempt = firstSend.current - if (!attempt || attempt.workspaceId !== wsId || attempt.agentId !== selectedAgentId) { - attempt = { workspaceId: wsId, agentId: selectedAgentId } - firstSend.current = attempt - } - let cid = attempt.conversationId - if (!cid) { - const conv = await createConversation(wsId, { - title: content.slice(0, 30), - surface: "web", - form: "thread", - agent_id: selectedAgentId, - }) - cid = conv.id - const createdAttempt = { ...attempt, conversationId: cid } - if (firstSend.current === attempt) firstSend.current = createdAttempt - attempt = createdAttempt - } - try { - await sendUserMessage(cid, { content }) - } finally { - // A failed first message still leaves a real conversation in the list. - qc.invalidateQueries({ - predicate: (q) => - q.queryKey[0] === "admin" && q.queryKey[1] === "conversations" && q.queryKey[2] === wsId, - }) - qc.invalidateQueries({ queryKey: ["admin", "conversationTimeline", cid] }) - } - if (firstSend.current !== attempt) return false - firstSend.current = null - writeConversationViewState(wsId, { - agentId: selectedAgentId, - conversationId: cid, - }) - navigate("conversations", { id: cid }) - return true - } + const { openCreate, handleSendFromEmpty, resetFirstSend } = useNewConversation(wsId, selectedAgentId, entityId) const renameMutation = useUpdateConversationTitle(wsId) const deleteMutation = useDeleteConversation(wsId) @@ -261,7 +197,7 @@ export function ConversationsPage() { canDelete={workspaceRole === "owner" || workspaceRole === "admin"} selectedAgentId={selectedAgentId} onPickAgent={(id) => { - firstSend.current = null + resetFirstSend() setPickedAgent({ workspaceId: wsId, agentId: id }) writeConversationViewState(wsId, { agentId: id }) navigate("conversations", { id: "", focus: "compose" }) @@ -270,7 +206,7 @@ export function ConversationsPage() { conversations={conversations} selectedConversationId={entityId ?? ""} onPickConversation={(id) => { - firstSend.current = null + resetFirstSend() writeConversationViewState(wsId, { agentId: selectedAgentId, conversationId: id, diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index 3f985399..06e306ad 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -1,11 +1,14 @@ import { Textarea } from "../../components/ui/textarea" -import { jsonObject, type CoreAgentConfig } from "../../lib/core-api" +import { jsonObject, coreExecutionDefaults, type CoreAgentConfig } from "../../lib/core-api" import { useId, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { Button } from "../../components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog" import { Input } from "../../components/ui/input" import { Label } from "../../components/ui/label" +import { useModelCatalog, supportsCatalogModel } from "../../lib/model-catalog" +import { AgentModelField } from "./agents/AgentModelField" +import { AgentExecutionFields } from "./agents/AgentExecutionFields" import { AgentInstructionsField } from "./agents/AgentInstructionsField" import { AgentVisibilityField } from "./agents/AgentVisibilityField" import { AgentSaveErrorDialog } from "./agents/AgentSaveErrorDialog" @@ -36,14 +39,17 @@ export function CreateAgentDialog(props: CreateAgentDialogProps) { const id = useId() const submitRef = useRef(null) const scopeRef = useRef(null) + const [execution, setExecution] = useState(() => coreExecutionDefaults(props.agent?.config)) const [name, setName] = useState(props.agent?.name ?? "") const [description, setDescription] = useState(props.agent?.description ?? "") - const [model, setModel] = useState(String(props.agent?.config?.model ?? "")) + const [modelID, setModelID] = useState(String(props.agent?.config?.model_id ?? "")) + const catalog = useModelCatalog(props.workspaceID) + const model = catalog.data?.models.find(row => row.id === modelID) const [instructions, setInstructions] = useState(String(props.agent?.config?.system_prompt ?? "")) const [advanced, setAdvanced] = useState(() => JSON.stringify(Object.fromEntries(Object.entries(props.agent?.config ?? {}).filter(([key]) => ["tools", "service_tier", "multi_agent", "reasoning", "text"].includes(key))), null, 2)) const [configurationError, setConfigurationError] = useState(null) const [visibility, setVisibility] = useState(props.agent?.visibility ?? "workspace") - const valid = name.trim() !== "" && model.trim() !== "" && props.workspaceID !== null + const valid = name.trim() !== "" && !!model && supportsCatalogModel(model, execution.harness) && execution.environment.type === "openai_hosted" && execution.harness !== "" && props.workspaceID !== null return
    { @@ -57,7 +63,7 @@ export function CreateAgentDialog(props: CreateAgentDialogProps) { } catch (error) { setConfigurationError(error instanceof Error ? error.message : t("core.failed")); return } props.onSubmit({ agentID: props.mode === "edit" ? props.agent?.id : undefined, - body: { name: name.trim(), description: description.trim(), connector_type: "agents_api", system_prompt: instructions, config: { ...extra, model: model.trim() } as CoreAgentConfig, ...(props.mode === "create" ? { visibility } : {}) }, + body: { name: name.trim(), description: description.trim(), connector_type: "agents_api", system_prompt: instructions, config: { ...extra, model: model!.model_key, model_id: modelID, environment: execution.environment, x_agents_core: { harness: execution.harness } } as CoreAgentConfig, ...(props.mode === "create" ? { visibility } : {}) }, }) }}> @@ -67,7 +73,9 @@ export function CreateAgentDialog(props: CreateAgentDialogProps) {
    setName(event.target.value)} disabled={props.pending} required />
    setDescription(event.target.value)} disabled={props.pending} />
    -
    setModel(event.target.value)} disabled={props.pending} required aria-describedby={`${id}-hint`} />

    {t("agents.core.modelHint")}

    + + setExecution(value => ({ ...value, harness }))} onEnvironmentChange={environment => setExecution(value => ({ ...value, environment }))} disabled={props.pending} /> + {model && (!supportsCatalogModel(model, execution.harness) || execution.environment.type !== "openai_hosted") &&

    {t("catalog.incompatible")}

    } {props.mode === "create" && }
    {t("core.advanced")}

    {t("core.agentAdvancedHint")}