Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,18 @@ try {
assert.match(summary, /high/);
assert.match(summary, /Fast/);
assert.doesNotMatch(summary, /next-model|low/, "Pending new settings never masquerade as the current Session");
assert.match(actual, new RegExp(locale === "zh-CN" ? "新对话配置" : "New conversation settings"));
assert.match(actual, new RegExp(locale === "zh-CN" ? "保留原历史和活动回合" : "existing history and active work are preserved"));
assert.match(actual, new RegExp(locale === "zh-CN" ? "后续消息" : "Subsequent messages"));
assert.match(actual, new RegExp(locale === "zh-CN" ? "当前回合和原历史不变" : "active work and history stay unchanged"));
const saved = render({readback: {agent_model_id: "actual-model", agent_reasoning_effort: "high",
requested_agent_model_id: "saved-model", requested_agent_reasoning_effort: "low", model_settings_supported: true}});
const savedSummary = saved.slice(saved.indexOf("<summary"), saved.indexOf("</summary>"));
assert.match(savedSummary, /saved-model/);
assert.doesNotMatch(savedSummary, /actual-model|next-model/);
assert.match(saved, /actual-model/, "The prior actual configuration remains visible inside the menu");
assert.match(savedSummary, new RegExp(locale === "zh-CN" ? "已保存,后续消息使用" : "Saved for subsequent messages"));
const owned = render({readback: {agent_model_id: "host-model", model_settings_supported: false}});
assert.match(owned, new RegExp(locale === "zh-CN" ? "原宿主或专属设置" : "owning host or dedicated settings"));
assert.doesNotMatch(owned, /<fieldset/, "Host-owned and legacy Sessions expose readback without a misleading save operation");
const unknown = render({});
const unknownSummary = unknown.slice(unknown.indexOf("<summary"), unknown.indexOf("</summary>"));
assert.match(unknownSummary, new RegExp(locale === "zh-CN" ? "模型设置" : "Model settings"));
Expand All @@ -32,7 +42,7 @@ try {
assert.doesNotMatch(unknownSummary, /next-model|low|Fast/, "Neither defaults nor a pending choice fill an unknown observation");
const partial = render({ readback: { agent_reasoning_effort: "high", agent_service_tier: "priority" } });
assert.doesNotMatch(partial.slice(partial.indexOf("<summary"), partial.indexOf("</summary>")), /high|Fast/, "Partial settings do not decorate a missing model as a complete current binding");
assert.doesNotMatch(render({ ordinary: false }), new RegExp(locale === "zh-CN" ? "新对话配置" : "New conversation settings"), "Steward settings keep their existing owner");
assert.doesNotMatch(render({ ordinary: false }), new RegExp(locale === "zh-CN" ? "后续消息" : "Subsequent messages"), "Steward settings keep their existing owner");
const defaultsLabel = locale === "zh-CN" ? "默认对话配置" : "Conversation defaults";
assert.match(render({ onOpenDefaults() {} }), new RegExp(defaultsLabel), "Defaults remain discoverable inside the relevant settings disclosure");
assert.doesNotMatch(render({ ordinary: false, onOpenDefaults() {} }), new RegExp(defaultsLabel), "Ordinary defaults are not offered for the steward binding");
Expand Down Expand Up @@ -64,8 +74,7 @@ try {
const restricted = header("idle", { ...stewardProps, managerRuntime: { runtime_profile: "restricted", sandbox: "read-only", status: "ready" } });
assert.match(restricted, /只读|Read-only/);
const goal = header("idle", { ...stewardProps, selectedGoal: { goalId: "public-case", title: "Public Goal", agentLanes: [] } });
assert.match(goal, /goal-only-model/);
assert.match(goal, /Fast/);
assert.doesNotMatch(goal, /goal-only-model|Fast|personal-chat-model-menu/, "The Goal reading header leaves sending choices in the single composer");
assert.doesNotMatch(goal, /gpt-6\.1-sol/, "Goal readback does not inherit the steward model");
const linked = header("idle", { readOnlySourceLabel: "Historical run", selectedGoal: { goalId: "source-case", title: "Historical run", agentLanes: [] } });
const sourceBadge = linked.match(/class="personal-read-only-source"[^>]*>(.*?)<\/span>/)?.[1] ?? "";
Expand Down
155 changes: 136 additions & 19 deletions apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,11 @@ export async function createChatSession(
}

export type AgentModelOption = { value: string; label: string; reasoning_efforts?: string[] };
export function configureChatSessionModel(sessionId: string, model: string, effort?: string) {
return requestJson<{ok: true; session: ChatSessionSummary}>(`/api/chat/sessions/${encodeURIComponent(sessionId)}/model-settings`, {
method: "POST", body: JSON.stringify({agent_model_id: model, ...(effort ? {agent_reasoning_effort: effort} : {})}),
});
}
export type AgentModelDiscovery = {
ok: true;
schema_version: "loopx_chat_agent_model_options_v1";
Expand Down Expand Up @@ -825,6 +830,9 @@ export type ChatSessionSummary = {
agent_model_id?: string | null;
agent_reasoning_effort?: string | null;
agent_service_tier?: string | null;
requested_agent_model_id?: string | null;
requested_agent_reasoning_effort?: string | null;
model_settings_supported?: boolean;
adapter_kind: string;
channel_id?: string;
status: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import type { AgentModelDiscovery, ChatSessionSummary } from "../../data/chat";

/** A late Session response must not replace a pending owner selection. */
export function modelSelectionFromReadback(
readback: Pick<ChatSessionSummary, "agent_model_id" | "agent_reasoning_effort">,
readback: Pick<ChatSessionSummary, "agent_model_id" | "agent_reasoning_effort" | "requested_agent_model_id" | "requested_agent_reasoning_effort">,
requested: { model?: string; effort?: string },
) {
const sameModel = !requested.model || requested.model === readback.agent_model_id;
const model = readback.requested_agent_model_id ?? readback.agent_model_id;
const effort = readback.requested_agent_reasoning_effort ?? readback.agent_reasoning_effort;
const sameModel = !requested.model || requested.model === model;
return {
model: sameModel ? readback.agent_model_id ?? "" : undefined,
effort: sameModel && (!requested.effort || requested.effort === readback.agent_reasoning_effort)
? readback.agent_reasoning_effort ?? "" : undefined,
model: sameModel ? model ?? "" : undefined,
effort: sameModel && (!requested.effort || requested.effort === effort) ? effort ?? "" : undefined,
};
}

/** Catalog choices describe the next binding; only Session readback is current. */
/** Catalog choices are candidates; requested and observed Session settings stay distinct. */
export function chatModelControls(
discovery: AgentModelDiscovery | null,
selectedModel: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Clock3 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { discoverAgentModelOptions, type AgentModelDiscovery, type ChatSessionSummary } from "../../data/chat";
import { ChatApiError, discoverAgentModelOptions, type AgentModelDiscovery, type ChatSessionSummary } from "../../data/chat";
import { ContextHelp } from "./context-help";
import { chatModelControls } from "./chat-model-controls";
import { useWorkspaceI18n } from "./i18n";
import { WorkspaceSelect } from "./workspace-select";

/** Reported models stay visible; missing observations belong in the settings disclosure. */
export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, readback, onModel, onEffort, onOpenDefaults }: {
agentId: string; goalId?: string; ordinary: boolean; model?: string; effort?: string;
readback?: ChatSessionSummary; onModel?: (value: string) => void; onEffort?: (value: string) => void;
readback?: ChatSessionSummary; onModel?: (value: string) => void | Promise<void>; onEffort?: (value: string) => void | Promise<void>;
onOpenDefaults?: () => void;
}) {
const { locale } = useWorkspaceI18n();
Expand All @@ -19,8 +20,24 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<Error | null>(null);
const retrySave = useRef<(() => void | Promise<void>) | null>(null);
const saveAttempt = useRef(0);
const editable = ordinary && readback?.model_settings_supported !== false;
useEffect(() => {
if (!open || !ordinary) return;
saveAttempt.current += 1; retrySave.current = null; setSaveError(null); setSaving(false);
}, [agentId, goalId, readback?.session_id]);
async function save(change: () => void | Promise<void>) {
const attempt = ++saveAttempt.current;
retrySave.current = change;
setSaving(true); setSaveError(null);
try { await change(); }
catch (cause) { if (attempt === saveAttempt.current) setSaveError(cause instanceof Error ? cause : new Error(String(cause))); }
finally { if (attempt === saveAttempt.current) setSaving(false); }
}
useEffect(() => {
if (!open || !editable) return;
const controller = new AbortController();
setDiscovery(null); setLoading(true); setError(null);
void discoverAgentModelOptions(agentId, controller.signal, goalId, model)
Expand All @@ -29,7 +46,7 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r
setError(cause instanceof Error ? cause.message : (zh ? "配置读取失败" : "Settings unavailable")); setLoading(false);
} });
return () => controller.abort();
}, [open, ordinary, agentId, goalId, model, retry, zh]);
}, [open, editable, agentId, goalId, model, retry, zh]);
useEffect(() => {
const close = (event: PointerEvent | KeyboardEvent) => {
if (!root.current?.open) return;
Expand All @@ -48,36 +65,49 @@ export function ChatModelMenu({ agentId, goalId, ordinary, model = "", effort, r
const observedSettings = controls.observedModel ? [controls.observedEffort,
controls.observedTier ? (["priority", "fast"].includes(controls.observedTier) ? "Fast" : controls.observedTier) : null,
].filter(Boolean).join(" · ") : "";
const nextModel = ordinary ? readback?.requested_agent_model_id : null;
const nextEffort = ordinary ? readback?.requested_agent_reasoning_effort : null;
const pending = Boolean(nextModel && (nextModel !== controls.observedModel || nextEffort && nextEffort !== controls.observedEffort));
const summarySettings = pending ? nextEffort ?? "" : observedSettings;
return <details className="personal-chat-model-menu" ref={root} onToggle={event => setOpen(event.currentTarget.open)}>
<summary aria-label={`${zh ? "模型与思考深度" : "Model and reasoning"} · ${observed}${observedSettings ? ` · ${observedSettings}` : ""}`}>
<span><span className="personal-model-current">{observed}</span>{observedSettings ? <small>{observedSettings}</small> : null}</span>
<summary aria-label={`${zh ? "模型与思考深度" : "Model and reasoning"} · ${nextModel ?? observed}${summarySettings ? ` · ${summarySettings}` : ""}${pending ? ` · ${zh ? "后续消息" : "Subsequent messages"}` : ""}`}>
<span><span className="personal-model-current">{nextModel ?? observed}</span>{summarySettings ? <small>{summarySettings}</small> : null}{pending ? <Clock3 size={13} aria-label={zh ? "已保存,后续消息使用;当前回合不变" : "Saved for subsequent messages; current turn unchanged"}/> : null}</span>
<ChevronDown aria-hidden size={16}/>
</summary>
<div className="personal-chat-model-panel">
<strong>{zh ? "当前会话" : "Current conversation"}</strong>
<strong>{zh ? "运行时回读" : "Runtime readback"}</strong>
<p>{controls.observedModel
? [controls.observedModel, controls.observedEffort ? `${zh ? "推理" : "Reasoning"} ${controls.observedEffort}` : null,
controls.observedTier ? `${zh ? "速度" : "Speed"} ${["priority", "fast"].includes(controls.observedTier) ? "Fast" : controls.observedTier}` : null].filter(Boolean).join(" · ")
: (zh ? "运行时尚未回传实际模型" : "Runtime has not reported the actual model")}</p>
{ordinary ? <>
<strong>{zh ? "新对话配置" : "New conversation settings"}</strong>
<p>{zh ? "用于下次新对话,保留原历史和活动回合。" : "Applies to a new conversation; existing history and active work are preserved."}</p>
{ordinary && !editable ? <p>{zh ? "请在此会话的原宿主或专属设置中调整配置。" : "Change this conversation in its owning host or dedicated settings."}</p> : null}
{editable ? <>
<div className="personal-model-next-heading"><strong>{zh ? "后续消息" : "Subsequent messages"}</strong>
<ContextHelp label={zh ? "配置生效范围" : "When settings apply"}><p>{zh ? "保存到当前会话,后续消息采用;当前回合和原历史不变。尚未建立会话时,用于首次发送。" : "Saved to this conversation for subsequent messages; active work and history stay unchanged. Before a conversation exists, used for its first message."}</p></ContextHelp></div>
<fieldset className="personal-model-options" disabled={saving}>
{controls.modelOptions.length ? <WorkspaceSelect ariaLabel={zh ? "选择模型" : "Select model"}
className="personal-agent-select personal-model-select" onChange={value => onModel?.(value)}
className="personal-agent-select personal-model-select" onChange={value => void save(() => onModel?.(value))}
prefixLabel={zh ? "模型" : "Model"} value={model} options={[
...(!model ? [{ disabled: true, label: zh ? "选择模型" : "Choose model", value: "" }] : []),
...(model && !controls.modelOptions.some(option => option.value === model)
? [{ disabled: true, label: `${model} · ${zh ? "未列入可选模型" : "Not in catalog"}`, value: model }] : []),
...controls.modelOptions.map(option => ({ label: option.value === model ? option.value : option.label, value: option.value })),
]}/> : null}
{controls.reasoningOptions.length && onEffort ? <WorkspaceSelect ariaLabel={zh ? "选择思考深度" : "Select reasoning effort"}
className="personal-agent-select personal-model-select" onChange={onEffort}
className="personal-agent-select personal-model-select" onChange={value => void save(() => onEffort(value))}
prefixLabel={zh ? "思考深度" : "Reasoning"} value={controls.effort} options={[
{ value: "", label: zh ? "沿用默认" : "Use default" },
...(!readback ? [{ value: "", label: zh ? "沿用默认" : "Use default" }] : []),
...(controls.effort && !controls.reasoningOptions.some(option => option.value === controls.effort)
? [{ value: controls.effort, label: `${controls.effort} · ${zh ? "当前不可用" : "Unavailable"}`, disabled: true }] : []),
...controls.reasoningOptions,
]}/> : null}
</fieldset>
{saving ? <p role="status">{zh ? "正在保存…" : "Saving…"}</p> : null}
{saveError ? <p role="alert">{zh
? (saveError instanceof ChatApiError && saveError.payload.error_code === "unsupported_configuration"
? "当前会话不支持所选模型或思考深度,已保存配置未更改。" : "配置保存未确认,请重试;当前配置以运行时回读为准。")
: saveError.message}<ContextHelp label={zh ? "保存失败原因" : "Settings error details"}><p>{saveError.message}</p></ContextHelp>
<button className="personal-action-button" type="button" disabled={saving} onClick={() => retrySave.current && void save(retrySave.current)}>{zh ? "重试保存" : "Retry save"}</button></p> : null}
{discovery && controls.modelOptions.length > 0 && !controls.reasoningOptions.length && model
? <p>{zh ? "运行时未提供思考深度选项" : "Runtime provides no reasoning choices"}</p> : null}
{discovery?.supported === false ? <p>{zh ? "执行器未提供可选模型与思考深度" : "Executor provides no model or reasoning choices"}</p> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { WorkspaceSelect } from "./workspace-select";
export function ComposerRuntimeControls({ agents, agentId, goalId, original, model, effort, readback, onAgent, onModel, onEffort, onDefaults }: {
agents: WorkspaceAgentOption[]; agentId: string; goalId?: string; original: boolean;
model?: string; effort?: string; readback?: ChatSessionSummary;
onAgent: (id: string) => void; onModel?: (id: string) => void;
onEffort?: (effort: string) => void; onDefaults?: () => void;
onAgent: (id: string) => void; onModel?: (id: string) => void | Promise<void>;
onEffort?: (effort: string) => void | Promise<void>; onDefaults?: () => void;
}) {
const { locale, t } = useWorkspaceI18n();
const statusOnly = agentId === "status-only";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ export type PersonalWorkspaceCallbacks = {
onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string,
settings?: LoopXModeSettings) => void;
onSelectAgent?: (agentId: string) => void;
onSelectAgentModel?: (agentId: string, modelId: string) => void;
onSelectAgentReasoningEffort?: (agentId: string, effort: string) => void;
onSelectAgentModel?: (agentId: string, modelId: string) => void | Promise<void>;
onSelectAgentReasoningEffort?: (agentId: string, effort: string) => void | Promise<void>;
onSelectChannel?: (channel: WorkspaceChannel) => void;
onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void;
onSelectTodo?: (todoId: string | null) => void;
Expand Down
Loading
Loading