mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
feat(studio): seat queued lines, restore built panels, and open the browser through the controller
A queued line is the composer's to show, not the transcript's: leaving it in the list split the running turn at the moment of typing and handed its work to a line the model had not read. It now seats at delivery, and the outbox shows only what is still owed. Several subsystems were built and never rendered. Plan, Jobs, Agents, the command palette and the reply actions are now mounted: the plan sits above the composer as a one-line summary that opens, the deck chips ride the run rail, and Ctrl K reaches sessions and commands. Theme work: the sidebar's greys were hardcoded past the token closure, so every migration silently missed them. Selection reuses the 2px run bar rather than an accent fill. A ratchet test now counts the literal-colour rules that outrank the closure. Browser: manual tabs route through control.BrowserOpen, so what the person opened is what the model can read and drive — there is no second implementation. POST /browser/open carries the refusal code browser.open_failed, and the CLI gets /browser for the same capability. Credentials: APIKeyEnvFor is the one derivation, beside the validator that judges it, and ErrInvalidCredentialKey gives callers an identity instead of a sentence — a provider named "129" now reports the name as the field to change, not the key that was pasted. serve: the kernel serves the built page at its own paths and answers page.not_built when none is mounted. Onboarding belongs to that page, so the second inline setup page is gone and TestServeStartsWithMissingProviderKey pins /provider-setup, which is what the kernel actually owes. The System One decision backends collapse onto the general mechanism: the hosted endpoint and its gateway become ordinary sources with a role, migrated once for installs that already hold the old shape. Also fixes three lint findings that predate this branch (a tagged switch in agent/task.go, http.MethodPost in serve/hub_picker_test.go), so golangci-lint is clean again. repolint's repo-wide ceilings were already exceeded at 9a4ead5cd; every total here is at or below that commit's. The per-file budgets Pane.tsx, Composer.tsx and the config/agent files now owe are to be paid by extraction, not by -update.
This commit is contained in:
1 parent
9a4ead5cd1
commit
d191b73fca
137 files changed
+3371
-3674
No files matched your search
@@ -19,6 +19,7 @@ import (
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/surface"
|
||||
"reasonix/internal/testenv"
|
||||
"reasonix/internal/traystate"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -50,7 +51,10 @@ func testHub(t *testing.T, cfg config.ServeConfig) (*serve.Hub, chan string) {
|
||||
})
|
||||
t.Cleanup(ctrl.Close)
|
||||
hubCfg := hostServeConfig(cfg)
|
||||
hub := serve.NewHub(serve.HubOptions{Serve: hubCfg, Surface: surface.Desktop, Grant: grantHostCapabilities})
|
||||
// A tray, because the tray routes exist only where there is one. Without it
|
||||
// those paths were never registered, and the boundary test that reads them
|
||||
// was answered by whatever the kernel served for an unknown path.
|
||||
hub := serve.NewHub(serve.HubOptions{Serve: hubCfg, Surface: surface.Desktop, Grant: grantHostCapabilities, Tray: stubTray{}})
|
||||
srv := serve.New(ctrl, bc, hubCfg)
|
||||
srv.SetPaneSink(bc)
|
||||
if _, err := hub.Adopt(srv, bc); err != nil {
|
||||
@@ -359,7 +363,7 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
t.Fatalf("the handshake said %+v", said)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, said.Origin+"/_studio/", nil)
|
||||
req, err := http.NewRequest(http.MethodGet, said.Origin+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -371,7 +375,7 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "<title>studio</title>") {
|
||||
t.Fatalf("GET /_studio/ = %d %q", resp.StatusCode, body)
|
||||
t.Fatalf("GET / = %d %q", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// The parent goes away. Nothing is signalled: the pipe closing is the whole
|
||||
@@ -434,3 +438,11 @@ func TestTrayEndpointsSitBehindTheSameBoundary(t *testing.T) {
|
||||
t.Errorf("GET /tray/prefs = %d %s, want 200", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// stubTray is a window for the tray routes to answer about. It reports the
|
||||
// quiet state a host reports before anything has run.
|
||||
type stubTray struct{}
|
||||
|
||||
func (stubTray) IconLive() bool { return false }
|
||||
func (stubTray) TrayFold() traystate.State { return traystate.State{} }
|
||||
func (stubTray) ApplyTrayPrefs(serve.TrayPrefs) {}
|
||||
@@ -67,7 +67,7 @@ async function run() {
|
||||
const url = new URL(win.webContents.getURL());
|
||||
|
||||
check("the page is loaded from the loopback kernel", url.hostname === "127.0.0.1" && url.protocol === "http:", url.href);
|
||||
check("the page is loaded from its own namespace", url.pathname === "/_studio/", url.pathname);
|
||||
check("the page is loaded at the root", url.pathname === "/", url.pathname);
|
||||
|
||||
const seen = await js(`(() => ({
|
||||
bridge: window.reasonixHost ? Object.keys(window.reasonixHost).sort() : null,
|
||||
@@ -143,7 +143,7 @@ async function run() {
|
||||
win.webContents.emit("will-navigate", { preventDefault: () => (prevented = true) }, "https://attacker.example/");
|
||||
check("a navigation off this origin is refused", prevented);
|
||||
prevented = false;
|
||||
win.webContents.emit("will-navigate", { preventDefault: () => (prevented = true) }, url.origin + "/_studio/x");
|
||||
win.webContents.emit("will-navigate", { preventDefault: () => (prevented = true) }, url.origin + "/x");
|
||||
check("a navigation inside this origin is allowed", !prevented);
|
||||
await js(`window.open('https://attacker.example/', '_blank')`).catch(() => {});
|
||||
await wait(400);
|
||||
|
||||
@@ -17,6 +17,35 @@
|
||||
document.documentElement.lang = zh ? "zh-CN" : "en";
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
// A shared serve link carries its credential in the fragment, which the
|
||||
// browser never sends. Trading it for the cookie has to happen before the
|
||||
// bundle makes its first request, and the fragment is wiped from the
|
||||
// address bar and history on the way past so it cannot be copied on.
|
||||
window.__rxAuth = (function () {
|
||||
try {
|
||||
var f = new URLSearchParams(window.location.hash.slice(1));
|
||||
var token = f.get("token");
|
||||
if (!token) return Promise.resolve();
|
||||
f.delete("token");
|
||||
var rest = f.toString();
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search + (rest ? "#" + rest : ""));
|
||||
return fetch("/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token: token }),
|
||||
}).then(function () {});
|
||||
} catch (e) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
})();
|
||||
if (window.__rxAuth) {
|
||||
var native = window.fetch.bind(window);
|
||||
window.fetch = function () {
|
||||
var args = arguments;
|
||||
return window.__rxAuth.then(function () { return native.apply(null, args); });
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* The boot screen owns the window from the first paint until the kernel
|
||||
|
||||
@@ -56,11 +56,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.switch", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "browser.address", kind: "view", target: "none", proof: "static" },
|
||||
@@ -102,6 +100,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// rename the same session through the same host call, so it is one id.
|
||||
{ id: "session.rename", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "queue.cancel", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "queue.send-now", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "queue.retry", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "queue.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "queue.move", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
@@ -168,7 +167,6 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "pane.view", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "analysis.signal", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "analysis.row", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "pane.dock", kind: "view", target: "none", proof: "interaction" },
|
||||
// The agent's browser as a person watches it: which of its pages is drawn,
|
||||
// and the controls a person has over the page on screen. They reach the
|
||||
// window's view of the page, never the kernel.
|
||||
@@ -285,15 +283,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "settings.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "decision.typesafe-url", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.typesafe-model", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.typesafe-key", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.laya-local", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.laya-python", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.laya-model", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.laya-url", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.laya-key", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "decision.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workbench.new-browser", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.close", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.mode", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -302,6 +292,20 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.clear-search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "reply.quote", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.retry", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.retry-now", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.configure", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "reply.more", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.download", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.run-detail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.file", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -26,3 +26,20 @@ describe("the shipped entry", () => {
|
||||
expect(inline).toMatch(/\.boot\s*\{[^}]*background:\s*var\(--boot-bg\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// A shared serve link carries its credential in the fragment, which the browser
|
||||
// never sends. The kernel's own pages used to trade it for the cookie; they are
|
||||
// gone, so this page does it — before the bundle's first request, and without
|
||||
// leaving the token in the address bar for the next person to copy.
|
||||
describe("a credential arriving in the link", () => {
|
||||
it("is traded for the cookie and wiped from the address bar", () => {
|
||||
for (const want of [
|
||||
"window.location.hash.slice(1)",
|
||||
'"/auth/token"',
|
||||
"window.history.replaceState",
|
||||
"window.fetch =",
|
||||
]) {
|
||||
expect(HTML).toContain(want);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "turn_phase",
|
||||
@@ -209,7 +210,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "stream_attempt",
|
||||
@@ -382,7 +384,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "phase",
|
||||
@@ -520,7 +523,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "stream_attempt",
|
||||
@@ -625,7 +629,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "turn_phase",
|
||||
@@ -682,7 +687,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "stream_attempt",
|
||||
@@ -732,7 +738,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "turn_phase",
|
||||
@@ -877,7 +884,8 @@
|
||||
{
|
||||
"kind": "turn_started",
|
||||
"authoredTurn": 1,
|
||||
"msgIndex": 1
|
||||
"msgIndex": 1,
|
||||
"modelRef": "test-model/x"
|
||||
},
|
||||
{
|
||||
"kind": "stream_attempt",
|
||||
|
||||
@@ -19,9 +19,6 @@ import { EN_COMPOSER } from "./en_composer";
|
||||
export const EN: Record<string, string> = {
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
"已挂载工作区": "Mounted workspaces",
|
||||
"选择聚焦工作区": "Choose focused workspace",
|
||||
"当前聚焦 · 已挂载 {n} 个": "Focused · {n} mounted",
|
||||
...EN_SETTINGS,
|
||||
...EN_METRICS,
|
||||
...EN_STORAGE,
|
||||
@@ -78,6 +75,9 @@ export const EN: Record<string, string> = {
|
||||
// ── 运行状态 ─────────────────────────────────────────────────────
|
||||
"空闲": "Idle",
|
||||
"运行中": "Running",
|
||||
"去「模型来源」声明档位": "Declare levels under Model sources",
|
||||
"这个端点没有报告推理档位。中转站通常不转发这项能力,在来源的「请求与能力」里可以声明。": "This endpoint reported no reasoning levels. Relays often do not forward the capability; you can declare it under the source’s requests and capabilities.",
|
||||
"本轮已发送 / 已接收的 token": "Tokens sent and received this turn",
|
||||
"查看生成速度详情": "View generation speed details",
|
||||
"生成速度": "Generation speed",
|
||||
"实时更新": "Live",
|
||||
@@ -98,6 +98,7 @@ export const EN: Record<string, string> = {
|
||||
"移动 {id} 的界面": "Move {id}'s view",
|
||||
"等待批准": "Waiting for approval",
|
||||
"等待工作区": "Waiting for the workspace",
|
||||
"等待模型回复": "Waiting for the model",
|
||||
"缓存": "Cache",
|
||||
"{n} 步 · {clock}": "{n} steps · {clock}",
|
||||
"{n} 秒": "{n}s",
|
||||
@@ -115,7 +116,6 @@ export const EN: Record<string, string> = {
|
||||
"工作区": "Workspaces",
|
||||
"工作空间": "Workspace",
|
||||
"搜索与快捷操作": "Search and commands",
|
||||
"添加工作区": "Add workspace",
|
||||
"切换工作区": "Switch workspace",
|
||||
"工具与集成": "Tools and integrations",
|
||||
"钱包与用量": "Wallet and usage",
|
||||
@@ -174,7 +174,6 @@ export const EN: Record<string, string> = {
|
||||
"正在切换…": "Switching…",
|
||||
"已切换全部放行。": "Full access enabled.",
|
||||
"后续工具不再请求确认;拒绝规则与沙盒边界仍然生效。": "Future tools will not ask for confirmation; deny rules and sandbox boundaries still apply.",
|
||||
"并排显示浏览器": "Show the browser beside the conversation",
|
||||
"这条命令里嵌套或间接地执行了别的命令。自动放行和宽泛的允许规则都无法确认真正会跑的是什么;要么批准这一条,要么切到全部放行。":
|
||||
"This command runs another command through nesting or indirection. Auto and broad allow rules cannot tell what would actually run; approve this one, or switch to full access.",
|
||||
"这一步会把密码、一次性验证码或银行卡信息输入网站。自动放行、该站点已有的授权和宽泛规则都不能代你回答;要么批准,要么切到全部放行。":
|
||||
@@ -289,7 +288,10 @@ export const EN: Record<string, string> = {
|
||||
"待送达": "Pending",
|
||||
"待发送": "To send",
|
||||
"{n} 条待发送": "{n} to send",
|
||||
"当前回复结束后发送": "Sends after the current response",
|
||||
"尚未送达,可以改,也可以取回": "Not delivered yet — edit it or take it back",
|
||||
"正在送出…": "Sending…",
|
||||
"立即发送": "Send now",
|
||||
"停止当前回复,把这条作为新一轮立即发出": "Stop the current response and send this as a new turn",
|
||||
"发送已暂停": "Sending is paused",
|
||||
"会话暂时只读,消息已为你保留": "This conversation is temporarily read-only; your message is preserved",
|
||||
"队列容量": "Queue capacity",
|
||||
@@ -762,6 +764,13 @@ export const EN: Record<string, string> = {
|
||||
"主模型无法识别的图片当前无人处理 —— 会在发送前被丢弃。为「看图」指定一个带「读图」标签的模型即可接管。":
|
||||
"Images the main model cannot read now have no reader — they are dropped before anything is sent. Assign Vision a model tagged as reading images and they get through.",
|
||||
"仅生成计划,不写入": "Drafts a plan, read-only",
|
||||
"决策": "Decision",
|
||||
"system_one 询问的后端": "The backend system_one asks",
|
||||
"未指定": "Not assigned",
|
||||
"尚无可用来源": "No source yet",
|
||||
"不使用": "Do not use",
|
||||
"在「模型来源」添加一个决策协议的来源后,这里就能选。": "Add a source on a decision protocol under Model sources, and it will appear here.",
|
||||
"默认全部跟随主模型;只有要为某件事换一个模型时才改这里。决策是例外,它问的是判断题,必须指向一个决策来源。": "Everything follows the main model by default; change one only to give that job a different model. Decision is the exception: it asks a question set, so it has to name a decision source.",
|
||||
"派发的子任务": "Work sent out",
|
||||
"看图": "Vision",
|
||||
"处理主模型无法识别的图片": "Reads images the main model cannot",
|
||||
@@ -1052,6 +1061,24 @@ export const EN: Record<string, string> = {
|
||||
"预览": "Preview",
|
||||
"文件内容": "File contents",
|
||||
"资源管理器": "Explorer",
|
||||
"快捷操作": "Quick actions",
|
||||
"改写": "Rewrite",
|
||||
"未开始": "Not started",
|
||||
"任务清单": "Task list",
|
||||
"引用到输入框": "Quote into the composer",
|
||||
"重新生成": "Regenerate",
|
||||
"当前回复会留在运行历史里": "The current reply stays in the run history",
|
||||
"按当前配置重试": "Retry as configured",
|
||||
"先调整模型与强度": "Adjust the model and effort first",
|
||||
"更多": "More",
|
||||
"这条回复": "This reply",
|
||||
"下载回复": "Download the reply",
|
||||
"这一轮无法重新生成": "This turn cannot be regenerated",
|
||||
"操作": "Action",
|
||||
"搜索会话或操作": "Search conversations or actions",
|
||||
"搜索会话、工具、设置…": "Search conversations, tools, settings…",
|
||||
"没有匹配的会话或操作": "Nothing matches",
|
||||
"↑ ↓ 选择 · Enter 打开 · Esc 关闭": "↑ ↓ to move · Enter to open · Esc to close",
|
||||
"搜索文件": "Search files",
|
||||
"清除搜索": "Clear search",
|
||||
"没有匹配的文件": "No matching files",
|
||||
|
||||
@@ -97,6 +97,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
|
||||
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
|
||||
"这台服务器不允许修改模型来源": "This server does not allow editing model sources",
|
||||
"打不开这个网页:{error}": "That page could not be opened: {error}",
|
||||
"名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。": "The name “{name}” cannot hold a key: the credential slot is derived from it, and a slot may not start with a digit. Use a name starting with a letter — the key itself is fine.",
|
||||
"这个内核没有带界面,只提供接口": "This kernel serves no interface, only its API",
|
||||
"请填写 API key": "An API key is required",
|
||||
"该 key 长度异常,可能粘贴了错误内容": "That key is too long — something else was probably pasted",
|
||||
"已连接,无需重复配置": "Already connected — no need to set it up again",
|
||||
|
||||
@@ -515,6 +515,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"单轮回复上限;留空由内核与模型共同决定。": "The per-turn reply limit; leave blank for the runtime and model to decide.",
|
||||
"模型目录": "Model catalogue",
|
||||
"至少手动添加一个模型 ID,也可尝试从接口读取": "Add at least one model ID manually, or try reading the endpoint",
|
||||
"这个协议没有模型列表接口,直接填写服务商给出的模型 ID": "This protocol has no model listing; enter the model id the service documents.",
|
||||
"决策 · System One": "Decision · System One",
|
||||
"检测中…": "Checking…",
|
||||
"验证连接并读取": "Check connection and read models",
|
||||
"连接可用 · 找到 {n} 个模型": "Connection works · {n} models found",
|
||||
|
||||
@@ -75,8 +75,9 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"失败步数": "Failed",
|
||||
"待确认": "Awaiting you",
|
||||
"内置 Browser": "Built-in Browser",
|
||||
"内置浏览": "Built in",
|
||||
"关闭 Browser": "Close Browser",
|
||||
"新建浏览器标签": "New browser tab",
|
||||
"调整浏览器宽度": "Resize the browser",
|
||||
"模型正在操作这个页面": "The model is working on this page",
|
||||
"网页地址": "Web address",
|
||||
"打开网页": "Open page",
|
||||
"打开": "Open",
|
||||
@@ -85,7 +86,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"Reasonix 内置 Browser": "Reasonix built-in Browser",
|
||||
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
|
||||
"正在打开网页…": "Opening page…",
|
||||
"页面空白通常表示网站禁止嵌入,可使用右上角按钮在外部浏览器打开。": "A blank page usually means the site blocks embedding. Use the button at the top right to open it in your external browser.",
|
||||
"部分站点禁止被嵌入(Google、GitHub 等),这里会显示「拒绝连接」。": "Some sites refuse to be embedded (Google, GitHub and others) and show a refused-to-connect page here.",
|
||||
"关闭内置 Browser": "Close the built-in Browser",
|
||||
"打开内置 Browser": "Open the built-in Browser",
|
||||
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
|
||||
|
||||
@@ -91,6 +91,9 @@ const SAID: Record<string, string> = {
|
||||
|
||||
// ── 来源:连接与授权 ─────────────────────────────────────────────
|
||||
"provider.editing_disabled": "这台服务器不允许修改模型来源",
|
||||
"browser.open_failed": "打不开这个网页:{error}",
|
||||
"provider.bad_key_slot": "名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。",
|
||||
"page.not_built": "这个内核没有带界面,只提供接口",
|
||||
"provider.key_required": "请填写 API key",
|
||||
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
|
||||
"provider.setup_done": "已连接,无需重复配置",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface DecisionModels {
|
||||
typeSafe: { baseUrl: string; model: string; keyEnv: string; hasKey: boolean };
|
||||
laya: { local: boolean; python: string; model: string; httpBaseUrl: string; httpKeyEnv: string; hasHttpKey: boolean };
|
||||
}
|
||||
|
||||
export interface DecisionModelsDraft extends DecisionModels {
|
||||
typeSafe: DecisionModels["typeSafe"] & { apiKey?: string };
|
||||
laya: DecisionModels["laya"] & { httpApiKey?: string };
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
planner: "",
|
||||
subagent: "deepseek/deepseek-flash",
|
||||
guardian: "",
|
||||
decision: "",
|
||||
vision: "",
|
||||
};
|
||||
|
||||
@@ -635,6 +636,10 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
return [];
|
||||
}
|
||||
|
||||
async browserOpen(url: string): Promise<BrowserTab> {
|
||||
return { id: "mock", target: "mock", url, title: url, active: true };
|
||||
}
|
||||
|
||||
async queue(): Promise<Queue> {
|
||||
return {
|
||||
revision: this.queueRevision,
|
||||
|
||||
@@ -13,9 +13,6 @@ import { MockBoundary } from "./mock_boundary";
|
||||
// MockPort satisfies AgentPort in one declaration, and each face keeps its own
|
||||
// file.
|
||||
export class MockProvider extends MockBoundary {
|
||||
private decision = { typeSafe: { baseUrl: "https://api.typesafe.ai", model: "system-one", keyEnv: "TYPESAFE_API_KEY", hasKey: false }, laya: { local: false, python: "python", model: "auto", httpBaseUrl: "", httpKeyEnv: "", hasHttpKey: false } };
|
||||
async decisionModels() { return this.decision; }
|
||||
async saveDecisionModels(value: typeof this.decision): Promise<void> { this.decision = value; }
|
||||
// Whether this machine has been given a provider. The fixture opens without
|
||||
// one so the connect card can be designed, and every port the same window
|
||||
// builds reads the same answer: a key belongs to the machine, not to the
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface ModelEntry {
|
||||
// nothing declares it — never "no". Rendering a guess here sends the user to
|
||||
// a rejected request they cannot explain.
|
||||
vision?: boolean;
|
||||
// What this model produces. Absent means chat: it is what every model was
|
||||
// before decision backends, so a kernel predating the field still answers.
|
||||
answers?: "chat" | "decision";
|
||||
efforts?: string[];
|
||||
effort?: string;
|
||||
contextWindow?: number;
|
||||
@@ -67,6 +70,10 @@ export interface RoleAssignments {
|
||||
subagent: string;
|
||||
guardian: string;
|
||||
vision: string;
|
||||
// The system_one backend. It names a model on a decision wire, so unlike the
|
||||
// others it never falls back to the main model: a chat model cannot answer a
|
||||
// question set, and a silent fallback would make a missing backend a wrong one.
|
||||
decision: string;
|
||||
}
|
||||
|
||||
// The two fold bounds and which of them is in force. They are configured
|
||||
|
||||
@@ -39,8 +39,6 @@ import type { ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, San
|
||||
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
export type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
import type { DecisionModels, DecisionModelsDraft } from "./decision";
|
||||
export type { DecisionModels, DecisionModelsDraft } from "./decision";
|
||||
|
||||
export type * from "./plugin";
|
||||
|
||||
@@ -124,8 +122,6 @@ export interface TrayPrefs {
|
||||
}
|
||||
|
||||
export interface AgentPort {
|
||||
decisionModels(): Promise<DecisionModels>;
|
||||
saveDecisionModels(value: DecisionModelsDraft): Promise<void>;
|
||||
providerSetup(): Promise<ProviderSetup | null>;
|
||||
saveProviderKey(apiKey: string): Promise<void>;
|
||||
models(): Promise<ModelEntry[]>;
|
||||
@@ -378,6 +374,10 @@ export interface AgentPort {
|
||||
queue(): Promise<Queue>;
|
||||
// The agent's open tabs, read on every browser_tabs_changed frame.
|
||||
browserTabs(): Promise<BrowserTab[]>;
|
||||
// Opens a page in the session's one browser. A tab the person opened is a tab
|
||||
// the agent can read and drive; the window keeping its own would be a page
|
||||
// neither could hand to the other.
|
||||
browserOpen(url: string, newTab: boolean): Promise<BrowserTab>;
|
||||
// The entry's full text. preview is cut to a manifest-sized line, so editing
|
||||
// against it would silently shorten what the user actually said.
|
||||
readQueued(itemId: string): Promise<string>;
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface ProviderEntry {
|
||||
// list is the kernel's and the words for it are ours: no label rides along.
|
||||
export interface Protocol {
|
||||
kind: string;
|
||||
// What a model on this wire produces. A decision wire returns verdicts to a
|
||||
// question set and holds no conversation, so it is offered for the decision
|
||||
// role and nowhere else. Absent means chat: it is what every wire was before
|
||||
// decision backends, and a kernel that predates the field still answers.
|
||||
answers?: "chat" | "decision";
|
||||
// Which model-listing shape this wire is discovered under. Protocols sharing
|
||||
// one value answer the same listing and cannot be told apart by a probe.
|
||||
discovery: string;
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface HistoryMessage {
|
||||
// approved plan's execution. It is the writer's own declaration — nothing
|
||||
// here decides it from the wording.
|
||||
hostAuthored?: boolean;
|
||||
// Guidance sent into a turn that was already running, rather than a turn of
|
||||
// its own. There is no checkpoint behind it, so nothing rewinds to it.
|
||||
steer?: boolean;
|
||||
// Which model wrote this assistant turn. A rebuild has no turn_started to
|
||||
// read it off, and the composer's current setting is a different fact.
|
||||
modelRef?: string;
|
||||
reasoning?: string;
|
||||
images?: number; // attachments on a user turn; an image-only one has no text
|
||||
toolCalls?: HistoryToolCall[];
|
||||
|
||||
@@ -597,6 +597,9 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
async browserTabs() {
|
||||
return (await this.get<BrowserTab[] | null>("/browser/tabs")) ?? [];
|
||||
}
|
||||
browserOpen(url: string, newTab: boolean) {
|
||||
return this.post0<BrowserTab>("/browser/open", { url, newTab });
|
||||
}
|
||||
async readQueued(itemId: string) {
|
||||
const r = await this.get<{ envelope?: { displayText?: string } }>("/inbox/items/" + encodeURIComponent(itemId));
|
||||
// Every stored entry has a body — the kernel refuses an empty one at
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { SseBoundary } from "./sse_boundary";
|
||||
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe } from "./port";
|
||||
import type { DecisionModels, DecisionModelsDraft } from "./decision";
|
||||
|
||||
// Where models come from: the accounts, the protocols their endpoints answer,
|
||||
// and what probing one found.
|
||||
export class SseProvider extends SseBoundary {
|
||||
decisionModels() { return this.get<DecisionModels>("/decision-models"); }
|
||||
saveDecisionModels(value: DecisionModelsDraft) { return this.post("/decision-models", value); }
|
||||
providers() {
|
||||
return this.get<ProviderEntry[]>("/providers");
|
||||
}
|
||||
|
||||
@@ -638,6 +638,10 @@ export interface WireEvent {
|
||||
// turn can carry two models, and the alternative is reading it off the last
|
||||
// phase marker — an adjacency the record does not even keep.
|
||||
source?: string;
|
||||
// Which model wrote this: the turn's on turn_started, a second model's on the
|
||||
// text it wrote. A reply with none is the turn's, and reading it off the
|
||||
// composer instead named the wrong one whenever the setting had moved on.
|
||||
modelRef?: string;
|
||||
// turn_started: which message this turn is about. authoredTurn numbers the
|
||||
// conversation's own turns, msgIndex the session log — a checkpoint's `turn`
|
||||
// is neither. A client mints its own user row, so this is the only thing
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface WorkspaceChange {
|
||||
oldPath?: string;
|
||||
// git porcelain XY, trimmed: "M", "A", "D", "R", "??".
|
||||
status: string;
|
||||
// How much the file differs by. Absent is "git did not say" — a binary file,
|
||||
// or one nothing counted — which is not the same as changed by nothing.
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceChanges {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialState, reduce, type SessionEvent, type SessionState } from "./session";
|
||||
import { fromHistory, initialState, reduce, type Item, type SessionEvent, type SessionState } from "./session";
|
||||
import { refreshTodos, restoreSession } from "./restore";
|
||||
import type { AgentPort, HistoryMessage, HostTodo } from "../port/port";
|
||||
|
||||
@@ -89,3 +89,17 @@ describe("where the task panel gets its list", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Reopening a session had every reply attributed to whatever the composer is
|
||||
// set to now, because only the live turn_started carried a model. The message
|
||||
// carries it, so a transcript read a day later still says who answered.
|
||||
describe("which model wrote a rebuilt reply", () => {
|
||||
it("comes from the message, not from the current setting", () => {
|
||||
const { items } = fromHistory([
|
||||
{ role: "user", content: "hi", msgIndex: 0 },
|
||||
{ role: "assistant", content: "hello", msgIndex: 1, modelRef: "yyds/claude-opus-4.8" },
|
||||
]);
|
||||
const say = items.find((i) => i.t === "say") as Extract<Item, { t: "say" }>;
|
||||
expect(say.model).toBe("yyds/claude-opus-4.8");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { nextId } from "./ids";
|
||||
// what the card renders, and putting it there would make every append rewrite it.
|
||||
const thoughtSince = new Map<string, number>();
|
||||
|
||||
export function appendText(items: Item[], text: string, field: "text" | "reasoning", source?: string): Item[] {
|
||||
export function appendText(items: Item[], text: string, field: "text" | "reasoning", source?: string, model?: string): Item[] {
|
||||
const last = items[items.length - 1];
|
||||
if (last && last.t === "say" && !last.done) {
|
||||
const next = items.slice();
|
||||
@@ -22,7 +22,7 @@ export function appendText(items: Item[], text: string, field: "text" | "reasoni
|
||||
}
|
||||
const id = nextId();
|
||||
if (field === "reasoning") thoughtSince.set(id, Date.now());
|
||||
return [...items, { t: "say", id, text: "", done: false, source, [field]: text }];
|
||||
return [...items, { t: "say", id, text: "", done: false, source, model, [field]: text }];
|
||||
}
|
||||
|
||||
// Settles the message still being written. turn_done settles every open one:
|
||||
|
||||
@@ -236,6 +236,26 @@ describe("a line that is still queued", () => {
|
||||
const read = reduce(waiting, { kind: "steer", text: "x" } as SessionEvent);
|
||||
expect(rows(read)[0].pending).toBe(false);
|
||||
});
|
||||
|
||||
// Its seat was booked when it was typed, which is not when it happened. Every
|
||||
// sentence and tool call that ran during the wait would otherwise sort after
|
||||
// a line the model had not yet read.
|
||||
it("seats the row where the turn read it, not where it was typed", () => {
|
||||
const waiting = run([typed("row-1", "later"), queued("row-1", "inbox-9", "steer")]);
|
||||
const worked = reduce(waiting, { kind: "text", text: "meanwhile" } as SessionEvent);
|
||||
const read = reduce(worked, { kind: "steer", text: "later", itemId: "inbox-9" } as SessionEvent);
|
||||
expect(read.items[read.items.length - 1]).toMatchObject({ t: "user", text: "later", pending: false });
|
||||
});
|
||||
|
||||
// The same rule for the other wait: a follow-up is a turn of its own, so its
|
||||
// row belongs at the turn the kernel started for it.
|
||||
it("seats a follow-up where its turn began", () => {
|
||||
const sent = ({ kind: "__user", text: "next", pending: false, id: "row-2" }) as SessionEvent;
|
||||
const waiting = run([sent, queued("row-2", "inbox-10", "followup")]);
|
||||
const worked = reduce(waiting, { kind: "text", text: "meanwhile" } as SessionEvent);
|
||||
const begun = reduce(worked, { kind: "turn_started", authoredTurn: 4, msgIndex: 9 } as SessionEvent);
|
||||
expect(begun.items[begun.items.length - 1]).toMatchObject({ t: "user", text: "next", pending: false, authoredTurn: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("waiting for another session to finish writing", () => {
|
||||
@@ -494,3 +514,27 @@ describe("restoring what a session has been billed", () => {
|
||||
expect(s.metrics.cost).toBe(0.02);
|
||||
});
|
||||
});
|
||||
|
||||
// A turn can run one model and hand part of it to another. Without the turn's
|
||||
// own name on its replies, a reader has only the composer's current setting,
|
||||
// which is a different fact and moves on its own.
|
||||
describe("which model wrote a reply", () => {
|
||||
const says = (st: SessionState) => st.items.filter((i): i is Extract<Item, { t: "say" }> => i.t === "say");
|
||||
|
||||
it("names the turn's model on the replies inside it", () => {
|
||||
const st = run([
|
||||
{ kind: "turn_started", authoredTurn: 1, msgIndex: 0, modelRef: "yyds/claude-opus-4.8" },
|
||||
{ kind: "text", text: "on it" },
|
||||
] as SessionEvent[]);
|
||||
expect(says(st)[0].model).toBe("yyds/claude-opus-4.8");
|
||||
});
|
||||
|
||||
// The second model names itself, and must not be recorded as the turn's.
|
||||
it("keeps a second model's own name on what it wrote", () => {
|
||||
const st = run([
|
||||
{ kind: "turn_started", authoredTurn: 1, msgIndex: 0, modelRef: "yyds/claude-opus-4.8" },
|
||||
{ kind: "text", text: "planning", source: "planner", modelRef: "deepseek/deepseek-pro" },
|
||||
] as SessionEvent[]);
|
||||
expect(says(st)[0].model).toBe("deepseek/deepseek-pro");
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,10 @@ export { setShowsReceipt, showsReceipt };
|
||||
// pass away from never matching again, and nothing fails when it stops.
|
||||
const RUNNING = "运行中";
|
||||
const WAITING_WORKSPACE = "等待工作区";
|
||||
// What the turn is doing between a tool finishing and the model's next packet.
|
||||
// The chip used to keep printing the tool that had already returned, so the
|
||||
// one stretch nothing is on screen for looked like the one stretch it hung.
|
||||
const WAITING_MODEL = "等待模型回复";
|
||||
|
||||
export const initialState: SessionState = {
|
||||
error: "",
|
||||
@@ -332,14 +336,14 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
// turn in front of you, not a record that one ever finished — without
|
||||
// this it would be the latter, and the tick from an hour ago would still
|
||||
// be on screen over work that is running now.
|
||||
return nameTurnStart({ ...s, running: true, doing: "运行中", terminal: null, waiting: { ttftSince: Date.now() } }, ev);
|
||||
return nameTurnStart({ ...s, running: true, doing: "运行中", terminal: null, turnModel: ev.modelRef || s.turnModel, waiting: { ttftSince: Date.now() } }, ev);
|
||||
|
||||
case "reasoning":
|
||||
return {
|
||||
...s,
|
||||
doing: "思考中",
|
||||
outWindow: sample(s.outWindow, estimateTokens(ev.text ?? ""), Date.now()),
|
||||
items: appendText(s.items, ev.text ?? "", "reasoning", ev.source),
|
||||
items: appendText(s.items, ev.text ?? "", "reasoning", ev.source, ev.modelRef || s.turnModel),
|
||||
};
|
||||
|
||||
case "text":
|
||||
@@ -347,7 +351,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
...s,
|
||||
doing: "正在回答",
|
||||
outWindow: sample(s.outWindow, estimateTokens(ev.text ?? ""), Date.now()),
|
||||
items: appendText(s.items, ev.text ?? "", "text", ev.source),
|
||||
items: appendText(s.items, ev.text ?? "", "text", ev.source, ev.modelRef || s.turnModel),
|
||||
};
|
||||
|
||||
case "message":
|
||||
@@ -381,7 +385,18 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
// A refused todo_write changed no host state, so its payload is not a plan.
|
||||
const own = ev.tool.name === "todo_write" && !ev.tool.parentId && !ev.tool.err;
|
||||
const plan = (own && parsePlan(ev.tool)) || s.plan;
|
||||
return { ...s, plan, executions, items: mergeReads(foldTool(s.items, ev.tool, false)) };
|
||||
// A returned call hands the turn back to the model, so the wait starts
|
||||
// again here — a sub-agent's call does not, because the parent turn was
|
||||
// never the thing waiting on it.
|
||||
const handedBack = !ev.tool.parentId && s.running;
|
||||
return {
|
||||
...s,
|
||||
plan,
|
||||
executions,
|
||||
doing: handedBack ? WAITING_MODEL : s.doing,
|
||||
waiting: handedBack ? { ...s.waiting, ttftSince: Date.now() } : s.waiting,
|
||||
items: mergeReads(foldTool(s.items, ev.tool, false)),
|
||||
};
|
||||
}
|
||||
|
||||
case "usage": {
|
||||
@@ -488,10 +503,15 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
|
||||
case "steer": {
|
||||
const q = s.steerQueue.filter((t) => t !== ev.text);
|
||||
const items = s.items.map((i) =>
|
||||
i.t === "user" && i.pending && i.text === ev.text ? { ...i, pending: false } : i,
|
||||
// The row enters where the turn read it, not where it was typed. Its seat
|
||||
// was booked at the wrong moment: the work that ran while it waited would
|
||||
// otherwise read as work done about it.
|
||||
const at = s.items.findIndex(
|
||||
(i) => i.t === "user" && i.pending && (ev.itemId ? i.itemId === ev.itemId : i.text === ev.text),
|
||||
);
|
||||
return { ...s, steerQueue: q, items };
|
||||
if (at < 0) return { ...s, steerQueue: q };
|
||||
const row = { ...(s.items[at] as Extract<Item, { t: "user" }>), pending: false, steer: true };
|
||||
return { ...s, steerQueue: q, items: [...s.items.slice(0, at), ...s.items.slice(at + 1), row] };
|
||||
}
|
||||
|
||||
case "notice": {
|
||||
@@ -716,7 +736,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
|
||||
// after an upgrade as well as for newly written history.
|
||||
const legacyHostRepair = m.content.startsWith("The following tools are unavailable in the current workflow phase:");
|
||||
const text = m.hostAuthored || legacyHostRepair ? "" : stripControl(m.content);
|
||||
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex });
|
||||
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex, steer: m.steer });
|
||||
continue;
|
||||
}
|
||||
if (m.role === "assistant") {
|
||||
@@ -730,7 +750,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
|
||||
// Thinking came before the search that follows it, so it cannot ride the
|
||||
// next text part when the turn opened with a search.
|
||||
if (reasoning && (parts.length === 0 || parts[0].search)) {
|
||||
out.push({ t: "say", id: nextId(), text: "", reasoning, done: true });
|
||||
out.push({ t: "say", id: nextId(), text: "", reasoning, done: true, model: m.modelRef });
|
||||
reasoning = undefined;
|
||||
}
|
||||
for (const part of parts) {
|
||||
@@ -745,7 +765,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions
|
||||
continue;
|
||||
}
|
||||
if (!part.text && !reasoning) continue;
|
||||
out.push({ t: "say", id: nextId(), text: part.text, reasoning, done: true });
|
||||
out.push({ t: "say", id: nextId(), text: part.text, reasoning, done: true, model: m.modelRef });
|
||||
reasoning = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export type Item =
|
||||
id: string;
|
||||
text: string;
|
||||
pending?: boolean;
|
||||
// Said into a turn already running. It never was a turn of its own, so it
|
||||
// has no checkpoint and nothing rewinds to it.
|
||||
steer?: boolean;
|
||||
itemId?: string;
|
||||
queued?: "steer" | "followup";
|
||||
authoredTurn?: number;
|
||||
@@ -23,7 +26,7 @@ export type Item =
|
||||
}
|
||||
// source names which model wrote it — a turn can carry two, and the card is
|
||||
// where the answer has to survive.
|
||||
| { t: "say"; id: string; text: string; reasoning?: string; done: boolean; thoughtMs?: number; source?: string }
|
||||
| { t: "say"; id: string; text: string; reasoning?: string; done: boolean; thoughtMs?: number; source?: string; model?: string }
|
||||
| { t: "tool"; id: string; tool: Tool; running: boolean; children: Tool[] }
|
||||
| { t: "reads"; id: string; tools: Tool[] }
|
||||
| { t: "guardian"; id: string; g: Guardian }
|
||||
@@ -166,6 +169,10 @@ export interface SessionState {
|
||||
//
|
||||
// Presentation bookkeeping, deliberately beside Item rather than on it: what
|
||||
// a card is does not depend on whether its arrival has been shown yet.
|
||||
// The model the running turn was started on. A reply names its own when a
|
||||
// second model wrote it; the rest are this one, recorded when the turn began
|
||||
// rather than read off the composer, which moves on.
|
||||
turnModel?: string;
|
||||
entranceOwed: string[];
|
||||
enteredThrough: number;
|
||||
// Bumped when the transcript's composition changes — a card added, settled,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import type { SessionState } from "./session_types";
|
||||
import type { Item, SessionState } from "./session_types";
|
||||
|
||||
// A client mints its own user row and nothing on the wire echoes it back, so
|
||||
// what joins the two is causation: the send that caused this turn is the row
|
||||
@@ -11,11 +11,14 @@ export function nameTurnStart(s: SessionState, ev: WireEvent): SessionState {
|
||||
const at = s.awaitingTurnStart.findIndex((id) => s.items.some((i) => i.t === "user" && i.id === id));
|
||||
if (at < 0) return { ...s, awaitingTurnStart: [] };
|
||||
const named = s.awaitingTurnStart[at];
|
||||
return {
|
||||
...s,
|
||||
awaitingTurnStart: s.awaitingTurnStart.slice(at + 1),
|
||||
items: s.items.map((i) =>
|
||||
i.t === "user" && i.id === named ? { ...i, authoredTurn: ev.authoredTurn, msgIndex: ev.msgIndex } : i,
|
||||
),
|
||||
};
|
||||
const seat = s.items.findIndex((i) => i.t === "user" && i.id === named);
|
||||
const was = s.items[seat] as Extract<Item, { t: "user" }>;
|
||||
const row = { ...was, authoredTurn: ev.authoredTurn, msgIndex: ev.msgIndex, pending: false };
|
||||
// A follow-up that waited in the queue takes its seat where its turn began,
|
||||
// under the same rule a delivered steer follows: the transcript is ordered by
|
||||
// what happened, and the wait belongs to the composer.
|
||||
const items = was.pending
|
||||
? [...s.items.slice(0, seat), ...s.items.slice(seat + 1), row]
|
||||
: s.items.map((i, k) => (k === seat ? row : i));
|
||||
return { ...s, awaitingTurnStart: s.awaitingTurnStart.slice(at + 1), items };
|
||||
}
|
||||
@@ -199,6 +199,39 @@ rules are written in.
|
||||
- Column widths change on `.app`, so the tween belongs there and `.cols` only
|
||||
reads the result into a track. The tween must be cut during a drag: a .34s
|
||||
tween makes the column chase the pointer.
|
||||
- The side track is closed unless a panel mounts in it. It was written open and
|
||||
closed by `[data-side="off"]`, and the attribute stayed wired to the built-in
|
||||
browser after that browser moved onto the workbench. Nothing rendered there
|
||||
and the shell still reserved 308px. `shellcolumns.test.ts` holds it.
|
||||
- Pane chrome has one left edge: the view switcher, the workbench's tab strip
|
||||
and the browser's toolbar all start at `--pane-gutter`. The switcher took the
|
||||
reading column's inset, right under a conversation and wrong under a
|
||||
full-bleed canvas — it floated 278px in from the strip below it.
|
||||
- A row nobody bounded is why the file tree had no scrollbar. `overflow: auto`
|
||||
was already on the list; the workbench body's implicit row is `auto`, sized to
|
||||
its content, so the column grew past the panel and was clipped instead.
|
||||
`grid-template-rows: minmax(0, 1fr)` is the half that makes the overflow real.
|
||||
- The workbench is hidden, not unmounted. It holds the open files and a browser
|
||||
per tab, each with its own history, and a glance at the conversation must not
|
||||
cost them. `.scroll[data-pane="browser"][hidden]` was already written for it.
|
||||
- The workbench's divider shares the browser's grid cell rather than being inset
|
||||
into the pane: that cell already starts under the nav and ends at the window.
|
||||
- It is in flow there, not absolute, so `inset: auto` is load-bearing:
|
||||
`.gutter-r`'s `right: -6px` becomes a relative offset and cancels the negative
|
||||
margin that straddles the seam.
|
||||
- Both seams are drawn. The studio layer had hidden `.gutter-l` and pinned
|
||||
`--rail-open` with `!important`, so the rail had no handle and a dragged width
|
||||
was read, stored, written inline and then overridden every time.
|
||||
- `.gutter-l` outranks the rail because the rail is `position: fixed` at 12: a
|
||||
seam painted under the panel it divides cannot be grabbed where it matters.
|
||||
- The rail lists every mounted workspace, each folding over its own sessions.
|
||||
The studio layer had hidden all but the focused one and relabelled its
|
||||
children 「最近」, so a count of six sat above a list of one. The switcher above
|
||||
still names where a *new* session lands; the tree is how an existing one is
|
||||
reached.
|
||||
- Only 「这台机器」 is hidden, by `[data-here]` rather than by `.machrow`: the
|
||||
section label above already carries that name, its count and its add button,
|
||||
while a remote host's row is the only thing naming that machine.
|
||||
- Focus mode takes the surroundings away and changes nothing else. The columns
|
||||
collapse rather than unmount — unmounting loses the side column's scroll
|
||||
position and the inspector's disclosure, and this is a temporary state that has
|
||||
|
||||
@@ -86,6 +86,10 @@ grid to break.
|
||||
floor, adjacent-pair colour-vision separation (ΔE ≥ 8), normal-vision floor
|
||||
(≥ 15), and contrast with the ground. In light, four segments fall below 3:1
|
||||
and rely on the legend's text labels.
|
||||
- `--io-up` / `--io-down` alias the categorical set, not the status one: sent
|
||||
and received are two kinds of one quantity, not two healths, so `net` or `ok`
|
||||
there would read as "running" or "succeeded". `--io-down` is `--cat-5`, which
|
||||
already is this figure — the context bar's output segment.
|
||||
|
||||
## Shape
|
||||
|
||||
|
||||
@@ -511,15 +511,6 @@
|
||||
margin-left: 0; padding: 2px 7px; border-radius: var(--r-xs);
|
||||
font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: .03em;
|
||||
}
|
||||
.qi[data-state="running"] { opacity: .55 }
|
||||
|
||||
.qi[data-state="steer_consumed"] {
|
||||
padding-top: 0; padding-bottom: 0; opacity: .5;
|
||||
border-color: transparent; background: none; font-size: var(--fs-meta);
|
||||
}
|
||||
.qi[data-state="steer_consumed"] .pill {
|
||||
padding: 0; background: none; font-size: var(--fs-micro); letter-spacing: .02em;
|
||||
}
|
||||
.pv { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.rf { flex: none; color: var(--faint) }
|
||||
.qwhy { flex: none; color: var(--warn-ink) }
|
||||
@@ -529,12 +520,25 @@
|
||||
background: var(--surface); color: var(--text); font: inherit; resize: none;
|
||||
}
|
||||
|
||||
.qacts { display: flex; gap: 1px; flex: none }
|
||||
.steermark {
|
||||
flex: none; padding: 1px 6px; border-radius: var(--r-pill);
|
||||
background: color-mix(in srgb, var(--accent) 13%, transparent); color: var(--accent-ink);
|
||||
font: var(--w-med) var(--fs-micro) var(--ui); letter-spacing: var(--track-cap);
|
||||
}
|
||||
|
||||
.qacts { display: flex; align-items: center; gap: 1px; flex: none }
|
||||
.qacts button {
|
||||
min-width: 17px; padding: 0 3px; border: none; border-radius: var(--r-xs);
|
||||
min-width: 17px; height: 19px; padding: 0 4px; border: none; border-radius: var(--r-xs);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; color: var(--faint); font: inherit; cursor: pointer;
|
||||
transition: color var(--m-line) , background var(--m-line) ;
|
||||
}
|
||||
.qacts .studio-icon { width: 12px; height: 12px; stroke-width: 1.9 }
|
||||
.qacts button.qnow {
|
||||
margin-left: 3px; padding: 0 8px; color: var(--accent-ink);
|
||||
font: var(--w-med) var(--fs-micro) var(--ui);
|
||||
}
|
||||
.qacts button.qnow:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent-ink) }
|
||||
.qacts button:hover:not(:disabled) { background: var(--overlay); color: var(--text) }
|
||||
.qacts button:disabled { opacity: .3; cursor: default }
|
||||
.qi .x {
|
||||
@@ -568,13 +572,14 @@
|
||||
.pcancel:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
|
||||
.app {
|
||||
--nav-w: 52px; --rail-w: var(--rail-open); --side-w: var(--side-open);
|
||||
transition: --rail-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --side-w var(--t-panel) cubic-bezier(.32, .72, 0, 1);
|
||||
--nav-w: 52px; --rail-w: var(--rail-open); --side-w: 0px; --dock-w: var(--dock-open);
|
||||
transition: --rail-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --side-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --dock-w var(--t-panel) cubic-bezier(.32, .72, 0, 1);
|
||||
}
|
||||
.app[data-rail="off"] { --rail-w: 0px }
|
||||
.app[data-side="off"] { --side-w: 0px }
|
||||
.app[data-browser="off"] { --dock-w: 0px }
|
||||
.app[data-side="on"] { --side-w: var(--side-open) }
|
||||
|
||||
.app[data-focus="true"] { --rail-w: 0px; --side-w: 0px }
|
||||
.app[data-focus="true"] { --rail-w: 0px; --side-w: 0px; --dock-w: 0px }
|
||||
[data-focus="true"] .rail, [data-focus="true"] .side { opacity: 0; border: 0 }
|
||||
|
||||
[data-focus="true"] .gutter-l, [data-focus="true"] .gutter-r, [data-focus="true"] .sidepeek { display: none }
|
||||
@@ -587,13 +592,13 @@
|
||||
.cols { flex: 1; display: grid; grid-template-columns: var(--nav-w) var(--rail-w) minmax(0, 1fr) var(--side-w); min-height: 0 }
|
||||
.rail, .side { overflow: hidden }
|
||||
[data-rail="off"] .rail { border-right: 0 }
|
||||
[data-side="off"] .side { border-left: 0 }
|
||||
.app:not([data-side="on"]) .side { border-left: 0 }
|
||||
|
||||
.rail > * { width: var(--rail-open); flex: none }
|
||||
.side > * { width: var(--side-open) }
|
||||
.rail > .scroll, .side > .scroll { flex: 1 }
|
||||
.rail, .side { transition: opacity var(--m-card) var(--m-step) }
|
||||
[data-rail="off"] .rail, [data-side="off"] .side { opacity: 0; transition: opacity var(--m-exit) }
|
||||
[data-rail="off"] .rail, .app:not([data-side="on"]) .side { opacity: 0; transition: opacity var(--m-exit) }
|
||||
|
||||
.sidepeek { display: none }
|
||||
[data-fold~="side"] .sidepeek {
|
||||
@@ -640,7 +645,7 @@
|
||||
cursor: col-resize; touch-action: none;
|
||||
}
|
||||
|
||||
.gutter-l { left: -6px }
|
||||
.gutter-l { left: -6px; z-index: 13 }
|
||||
.gutter-r { right: -6px }
|
||||
.gutter::after {
|
||||
content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; margin-left: -1px;
|
||||
@@ -659,10 +664,8 @@
|
||||
|
||||
.gutter[data-shut] { width: 14px }
|
||||
[data-rail="off"] .gutter-l { left: 0 }
|
||||
[data-side="off"] .gutter-r { right: 0 }
|
||||
|
||||
[data-rail="off"] .gutter-l .grip { margin-left: -6px }
|
||||
[data-side="off"] .gutter-r .grip { margin-left: -10px }
|
||||
.grip {
|
||||
position: absolute; top: 50%; left: 50%; margin: -16px 0 0 -8px; z-index: 1;
|
||||
width: 16px; height: 32px; padding: 0; display: grid; place-items: center;
|
||||
@@ -805,7 +808,7 @@
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 2px; flex: none;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
padding: 8px 0; padding-inline: max(16px, calc((100% - var(--wide)) / 2 + 18px));
|
||||
padding: 8px 0; padding-inline: var(--pane-gutter);
|
||||
}
|
||||
.vtabs { display: flex; align-items: center; gap: 2px; min-width: 0 }
|
||||
|
||||
@@ -850,8 +853,23 @@
|
||||
.pane:has(> .pbody[data-dock]) > .scroll[data-pane="browser"] {
|
||||
grid-area: side; min-width: 0; border-left: 1px solid var(--hair);
|
||||
}
|
||||
.tab.dock { padding-inline: 8px; font-size: var(--fs-meta) }
|
||||
.tab.dock[aria-pressed="true"] { background: var(--overlay); color: var(--text) }
|
||||
/* The divider shares the browser's cell rather than being inset into the
|
||||
pane: the cell already starts under the nav and ends at the window, which
|
||||
is the run the handle has to cover. */
|
||||
.pane:has(> .pbody[data-dock]) > .gutter-r {
|
||||
position: relative; inset: auto; grid-area: side; justify-self: start; align-self: stretch;
|
||||
width: 11px; margin-left: -6px; z-index: 6;
|
||||
}
|
||||
/* Shut, the column has no width to straddle, so the handle comes wholly
|
||||
inside the window rather than hanging half off its edge. */
|
||||
.app[data-browser="off"] .pane > .gutter-r { margin-left: -14px }
|
||||
[data-focus="true"] .pane > .gutter-r { display: none }
|
||||
/* Docked, the body has no room for two columns — so the explorer is a panel
|
||||
the tab bar opens rather than a column that is simply absent. */
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) { grid-template-columns: minmax(0, 1fr) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body:not([data-files]) .workbench-explorer { display: none }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] { grid-template-columns: minmax(0, 1fr) }
|
||||
.pane:has(> .pbody[data-dock]) .workbench-body[data-files] .workbench-canvas { display: none }
|
||||
.bpanel { flex: 1; display: flex; flex-direction: column; min-height: 0 }
|
||||
.bbar { flex: none; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--hair) }
|
||||
.bbar .vtabs { overflow-x: auto }
|
||||
@@ -968,7 +986,7 @@
|
||||
@keyframes edgein { to { opacity: 1 } }
|
||||
}
|
||||
|
||||
.pane { --srail-w: 54px; --flow-pad: 20px; --dock-w: clamp(360px, 42%, 760px) }
|
||||
.pane { --srail-w: 54px; --flow-pad: 20px }
|
||||
.railhost { position: sticky; top: 0; height: 0; z-index: 4; pointer-events: none }
|
||||
|
||||
[data-pane="flow"] { scrollbar-width: none }
|
||||
@@ -2361,12 +2379,16 @@
|
||||
.wsfind .clr { flex: none; border: 0; background: none; cursor: pointer; color: var(--ghost); font-size: var(--fs-ui); line-height: 1; padding: 0 2px }
|
||||
.wsfind .clr:hover { color: var(--text) }
|
||||
|
||||
/* Quiet, not absent. A control that exists only under the pointer is one
|
||||
nobody finds, and the row it sits on is where a person looks for it. */
|
||||
.reask-open {
|
||||
position: relative; flex: none; border: 0; background: var(--overlay); color: var(--faint); cursor: pointer;
|
||||
font: var(--w-med) var(--fs-micro) var(--ui); padding: 4px 9px; border-radius: var(--r-sm); white-space: nowrap;
|
||||
opacity: 0; transition: opacity var(--m-line), background var(--m-line), color var(--m-line);
|
||||
position: relative; flex: none; display: inline-flex; align-items: center; gap: 4px;
|
||||
border: 0; background: transparent; color: var(--faint); cursor: pointer;
|
||||
font: var(--w-med) var(--fs-micro) var(--ui); padding: 4px 8px; border-radius: var(--r-sm); white-space: nowrap;
|
||||
opacity: .58; transition: opacity var(--m-line), background var(--m-line), color var(--m-line);
|
||||
}
|
||||
.call > .c:hover .reask-open, .call > .c:focus-within .reask-open { opacity: 1 }
|
||||
.reask-open .studio-icon { width: 12px; height: 12px }
|
||||
.call > .c:hover .reask-open, .call > .c:focus-within .reask-open { opacity: 1; background: var(--overlay) }
|
||||
.reask-open::after { content: ""; position: absolute; inset: -5px -2px }
|
||||
.reask-open:hover { background: var(--border); color: var(--text) }
|
||||
.reask-open:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
|
||||
@@ -2625,9 +2647,24 @@
|
||||
.plan .s { display: flex; align-items: baseline; gap: 9px; padding: 6px 0 6px 10px; font-size: var(--fs-ui); color: var(--muted); border-bottom: 1px solid var(--hair); transition: color var(--m-card), opacity var(--m-card), background var(--m-line); border-radius: var(--r-xs) }
|
||||
.plan .s:hover { background: var(--overlay) }
|
||||
.plan .s:last-child { border-bottom: 0 }
|
||||
.plan .s .b { font: var(--w-reg) var(--fs-micro) var(--mono); width: 10px; flex: none; color: var(--ghost); font-variant-numeric: tabular-nums; transition: color var(--m-card) }
|
||||
/* Three states, three shapes: a dotted ring has not started, a turning arc is
|
||||
in hand, a filled disc is done. Colour alone would say it to some readers
|
||||
and to no one using the window in a bright room. */
|
||||
.plan .s .b {
|
||||
width: 11px; height: 11px; flex: none; align-self: center; border-radius: 50%;
|
||||
border: 1.5px dotted var(--ghost); box-sizing: border-box;
|
||||
transition: border-color var(--m-card), background var(--m-card);
|
||||
}
|
||||
.plan .s[data-now] .b {
|
||||
border: 1.5px solid var(--hair); border-top-color: var(--accent); border-right-color: var(--accent);
|
||||
animation: plan-turn 820ms linear infinite;
|
||||
}
|
||||
@keyframes plan-turn { to { transform: rotate(360deg) } }
|
||||
.plan .s[data-done] { color: var(--faint) }
|
||||
.plan .s[data-done] .b { color: var(--ok) }
|
||||
.plan .s[data-done] .b { border: 1.5px solid var(--ok); background: var(--ok) }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.plan .s[data-now] .b { animation: none; border-color: var(--accent) }
|
||||
}
|
||||
|
||||
.plan .s .t .ln, .steps .s .t .ln {
|
||||
background: linear-gradient(var(--ghost), var(--ghost)) no-repeat 0 62% / 0 1px;
|
||||
@@ -2789,7 +2826,7 @@
|
||||
|
||||
[data-fold~="side"] [data-side="on"] .gutter-r { display: none }
|
||||
|
||||
[data-fold~="side"] [data-side="off"] .side { display: none }
|
||||
[data-fold~="side"] .app:not([data-side="on"]) .side { display: none }
|
||||
|
||||
[data-fold~="short"] .compose { --aux-budget: min(calc(24vh / var(--zoom, 1)), 176px) }
|
||||
[data-fold~="short"] .compose textarea { max-height: min(calc(var(--read, 15px) * 9.6), calc(24vh / var(--zoom, 1))) }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./app.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
// `.cols` names four tracks and the window has three panels to put in them, so
|
||||
// the fourth is only ever as wide as something mounted in it asks for. It was
|
||||
// written the other way round — open by default, closed by an attribute — and
|
||||
// the attribute was being driven by the built-in browser, which had since moved
|
||||
// onto the workbench. The shell went on reserving 308px for a panel that no
|
||||
// longer existed anywhere, and the workbench lost that much of the window every
|
||||
// time the browser was opened.
|
||||
describe("the shell reserves a column only for a panel that mounts in it", () => {
|
||||
const css = Object.values(CSS)[0];
|
||||
|
||||
it("closes the side track by default", () => {
|
||||
// The rule that names the tracks, not whichever `.app` block comes first.
|
||||
const app = css.match(/\n\s*\.app\s*\{([^}]*--nav-w[^}]*)\}/);
|
||||
expect(app, "no .app track rule found; this file matches nothing").not.toBeNull();
|
||||
expect(app![1]).toMatch(/--side-w:\s*0px/);
|
||||
});
|
||||
|
||||
it("opens it from the attribute that says a panel is there", () => {
|
||||
expect(css).toMatch(/\.app\[data-side="on"\]\s*\{\s*--side-w:\s*var\(--side-open\)/);
|
||||
});
|
||||
|
||||
it("leaves no rule that reopens it from the attribute's absence", () => {
|
||||
expect(css).not.toMatch(/\[data-side="off"\]/);
|
||||
});
|
||||
});
|
||||
@@ -29,16 +29,23 @@ describe("the Studio shell closes over the active theme", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps conversation selection quiet instead of painting an accent rail", () => {
|
||||
// Selection was a 4% darkening of the surface, invisible under a theme pack:
|
||||
// the open conversation read as unselected. It is the same short bar a
|
||||
// running row gets, in the theme's accent. The row itself stays a plain
|
||||
// surface — a filled one is the loudest thing in the list at any saturation.
|
||||
it("marks the open conversation with a bar, not a filled row", () => {
|
||||
const selected = closure.match(/\.rail \.sessrow\[aria-selected="true"\]\s*\{([^}]*)\}/s)?.[1] ?? "";
|
||||
expect(selected).toContain("var(--overlay)");
|
||||
expect(selected).not.toContain("var(--accent-wash)");
|
||||
expect(selected).not.toContain("var(--accent)");
|
||||
expect(selected).toContain("box-shadow: none");
|
||||
const css = Object.values(CSS)[0];
|
||||
expect(css).toMatch(/\.rail \.sessrow\[aria-selected="true"\]::before/);
|
||||
});
|
||||
|
||||
// The scale is the reader's size and weight, not a literal: a pack states
|
||||
// colours and cannot move either, which is what this has always claimed.
|
||||
it("pins the composer to the Studio type scale even when a theme pack is active", () => {
|
||||
expect(closure).toMatch(/\.compose textarea\s*\{[^}]*font:\s*400 13px\/1\.65 var\(--ui\)/s);
|
||||
expect(closure).toMatch(/\.compose textarea\s*\{[^}]*font:\s*var\(--w-reg\) var\(--read\)\/1\.65 var\(--ui\)/s);
|
||||
expect(closure).toMatch(/\.compose textarea::placeholder\s*\{[^}]*font:\s*inherit/s);
|
||||
expect(closure).toMatch(/\.compose \.queue\s*\{[^}]*font:\s*500 10px\/1\.35 var\(--ui\)/s);
|
||||
expect(closure).toMatch(/\.compose \.mode,[\s\S]*?font-size:\s*11px !important/s);
|
||||
@@ -64,3 +71,24 @@ describe("the Studio shell closes over the active theme", () => {
|
||||
expect(selected).not.toContain("var(--accent)");
|
||||
});
|
||||
});
|
||||
|
||||
// The closure at the foot of this file only outranks rules of equal weight. A
|
||||
// scheme-scoped literal — `:root[data-theme="light"] .x { color: #aaa }` — is
|
||||
// heavier than the closure's `.x`, so it wins wherever the two name the same
|
||||
// thing, and the migration reads as done while nothing changed. That is how the
|
||||
// activity card and the session rail each kept a fixed palette under a theme
|
||||
// pack. The count may fall freely; a new one has to take a rule out first.
|
||||
describe("scheme-scoped literals, which outrank the closure", () => {
|
||||
const css = Object.values(CSS)[0];
|
||||
const SCHEME_LITERAL = /^:root\[data-theme=[^\n]*#[0-9a-fA-F]{3,8}/gm;
|
||||
const CARRIED = 149;
|
||||
|
||||
it("is not growing", () => {
|
||||
const found = css.match(SCHEME_LITERAL) ?? [];
|
||||
expect(
|
||||
found.length,
|
||||
`scheme-scoped literal rules: ${found.length}. Put the difference in tokens.css instead — ` +
|
||||
"it already redefines every token per scheme, and a rule here silently beats the closure.",
|
||||
).toBeLessThanOrEqual(CARRIED);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,13 +25,14 @@
|
||||
|
||||
@property --rail-w { syntax: "<length>"; inherits: true; initial-value: 264px }
|
||||
@property --side-w { syntax: "<length>"; inherits: true; initial-value: 316px }
|
||||
@property --dock-w { syntax: "<length>"; inherits: true; initial-value: 560px }
|
||||
|
||||
@property --win-ar { syntax: "<number>"; inherits: true; initial-value: 1.6 }
|
||||
|
||||
:root {
|
||||
interpolate-size: allow-keywords;
|
||||
|
||||
--rail-open: 264px; --side-open: 316px;
|
||||
--rail-open: 264px; --side-open: 316px; --dock-open: 560px; --pane-gutter: 12px;
|
||||
--page: oklch(95.8% .0092 85); --surface: oklch(98.9% .0046 85);
|
||||
--raised: oklch(100% 0 85); --overlay: oklch(93.1% .0138 85);
|
||||
--border: oklch(87.6% .0184 85); --hair: oklch(91.7% .0126 85);
|
||||
@@ -54,6 +55,7 @@
|
||||
--accent-ink: #884F00; --warn-ink: oklch(51% .120 45); --ok-ink: #286F44; --err-ink: #AF362E;
|
||||
|
||||
--cat-1: #2a78d6; --cat-2: #eb6834; --cat-3: #1baf7a; --cat-4: #eda100; --cat-5: #e87ba4;
|
||||
--io-up: var(--cat-1); --io-down: var(--cat-5);
|
||||
|
||||
--mono: "IBM Plex Mono var", ui-monospace, "Cascadia Code", "SF Mono", SFMono-Regular, Consolas, "Liberation Mono", Menlo, "Noto Sans SC var", monospace;
|
||||
--ui: "Inter var", "Noto Sans SC var", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei UI", Arial, sans-serif;
|
||||
|
||||
@@ -54,6 +54,10 @@ export function AddProvider({
|
||||
const [noProxy, setNoProxy] = useState(false);
|
||||
const extraBad = extra.trim() !== "" && parseExtraBody(extra) === null;
|
||||
const protocolCanThink = catalog.find((p) => p.kind === kind)?.reasoningParams ?? false;
|
||||
// A wire with no listing shape has nothing to read: the model id is declared
|
||||
// rather than discovered, and offering a probe that must fail reads as a
|
||||
// broken endpoint instead of a protocol that never had one.
|
||||
const protocolCanProbe = (catalog.find((p) => p.kind === kind)?.discovery ?? "") !== "";
|
||||
|
||||
// A source already at this host changes what a blank key means: another door
|
||||
// onto that account rather than an account with no credential.
|
||||
@@ -298,10 +302,15 @@ export function AddProvider({
|
||||
|
||||
<div className="addp-section">
|
||||
<div className="addp-section-head">
|
||||
<div><strong>{t("模型目录")}</strong><span>{t("至少手动添加一个模型 ID,也可尝试从接口读取")}</span></div>
|
||||
<button className="act quiet" data-action="provider.probe" onClick={connect} disabled={busy || checkingModel !== "" || baseUrl.trim() === ""}>
|
||||
{t(busy ? "检测中…" : "验证连接并读取")}
|
||||
</button>
|
||||
<div>
|
||||
<strong>{t("模型目录")}</strong>
|
||||
<span>{t(protocolCanProbe ? "至少手动添加一个模型 ID,也可尝试从接口读取" : "这个协议没有模型列表接口,直接填写服务商给出的模型 ID")}</span>
|
||||
</div>
|
||||
{protocolCanProbe && (
|
||||
<button className="act quiet" data-action="provider.probe" onClick={connect} disabled={busy || checkingModel !== "" || baseUrl.trim() === ""}>
|
||||
{t(busy ? "检测中…" : "验证连接并读取")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{probe && <p className="probe-ok">{t("连接可用 · 找到 {n} 个模型", { n: probe.models.length })}</p>}
|
||||
<div className="mlist">
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
import type { AccountState, AgentPort, Appearance as Look, ProviderSetup, ThemePack } from "../port/port";
|
||||
import type { AccountState, AgentPort, ProviderSetup } from "../port/port";
|
||||
import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
|
||||
import { Chrome } from "./Chrome";
|
||||
import { useLaunchHealth } from "./launchhealth";
|
||||
import { Nav } from "./Nav";
|
||||
import { AccountRow } from "./AccountRow";
|
||||
import { apply as applyThemePack } from "./theme";
|
||||
import { apply as applyLook } from "./look";
|
||||
import { adopt as adoptLang } from "../i18n";
|
||||
import { Pane, type PaneReport } from "./Pane";
|
||||
import { Gutter, RAIL, widthOf } from "./Gutter";
|
||||
import { DOCK, Gutter, RAIL, keepWidth, widthOf } from "./Gutter";
|
||||
import { folded as roomGaveUp, onFolds } from "./viewport";
|
||||
import { trailing } from "./trailing";
|
||||
import { RemoteAsk } from "./RemoteAsk";
|
||||
import { RemoteHosts } from "./RemoteHosts";
|
||||
import type { RemoteAsk as RemoteAskT, RemoteHost } from "../port/remote";
|
||||
import { RailSearch } from "./railsearch";
|
||||
import { Boundary } from "./Boundary";
|
||||
import { SettingsUnavailable } from "./SettingsUnavailable";
|
||||
import { useMachineBooks } from "./machinebooks";
|
||||
import { Workspaces } from "./Workspaces";
|
||||
import { Sky } from "./Sky";
|
||||
import { useAddWorkspace } from "./addws";
|
||||
import { usePaint } from "./paint";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
|
||||
import { PaneTabs } from "./PaneTabs";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
// Start fetching the settings chunk with the shell instead of waiting for the
|
||||
// first click. It remains a separate chunk (and keeps its failure boundary),
|
||||
@@ -107,38 +99,28 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// A connect stopped for a question. One at a time by construction: the link
|
||||
// that asked is blocked until it is answered.
|
||||
const [ask, setAsk] = useState<RemoteAskT | null>(null);
|
||||
const [folded, setFolded] = useState<Set<string>>(new Set());
|
||||
// 窄到放不下工作区栏时它是收起的,而不是消失的:栏一旦从 DOM 里拿掉,把手也
|
||||
// 跟着没了,剩下的入口只有一个没人知道的快捷键。
|
||||
const [rail, setRail] = useState(() => !roomGaveUp("rail"));
|
||||
const [railScope, setRailScope] = useState<"all" | "live" | "pinned" | "archived">("all");
|
||||
const [pinnedSessions, setPinnedSessions] = useState<Set<string>>(savedPins);
|
||||
const [showProjects, setShowProjects] = useState(false);
|
||||
// 三层所有权,只在用的地方相乘:wanted(用户的选择,rail 在 useFoldAway 的
|
||||
// 记忆里、side 还在盘上)、allowed(当前视口,useFoldAway 管)、focus(临时
|
||||
// 观看状态)。focus 绝不调 setRail/chooseSide —— 那会把临时状态写回偏好,退
|
||||
// 出后就恢复不了了。它也不存盘:重开一次窗口不该还在专注里。
|
||||
const [focus, setFocus] = useState(false);
|
||||
const [railW, setRailW] = useState(() => widthOf(RAIL));
|
||||
const [dockW, setDockW] = useState(() => widthOf(DOCK));
|
||||
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
||||
const [findPulse, setFindPulse] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
// false = closed, true = open at its last section, a string = open there.
|
||||
const [settings, setSettings] = useState<string | boolean>(false);
|
||||
const [browser, setBrowser] = useState(false);
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("rx-theme") ?? "auto");
|
||||
// "" means never chosen, which is what lets the system's own contrast setting
|
||||
// decide. Any explicit pick wins over it from then on.
|
||||
const [contrast, setContrast] = useState(() => localStorage.getItem("rx-contrast") ?? "");
|
||||
// 空串是「跟随语言」:中文界面本来就该比西文粗一档,样式表按 :lang 给默认。
|
||||
const [weight, setWeight] = useState(() => localStorage.getItem("rx-weight") ?? "");
|
||||
const [setup, setSetup] = useState<ProviderSetup | null | undefined>(undefined);
|
||||
// undefined until asked; false means the opening sequence is still owed.
|
||||
const [welcomed, setWelcomed] = useState<boolean | undefined>(undefined);
|
||||
const [account, setAccount] = useState<AccountState | null>(null);
|
||||
const [accountUnread, setAccountUnread] = useState("");
|
||||
const [pack, setPack] = useState<ThemePack | null>(null);
|
||||
const [look, setLook] = useState<Look>({});
|
||||
// Bumped when a pane is rebound to another transcript. It rides the Pane key,
|
||||
// so the takeover remounts it: every bit of what is on screen belonged to the
|
||||
// conversation it just left.
|
||||
@@ -296,85 +278,9 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
}, [activePort]);
|
||||
useEffect(reloadAccount, [reloadAccount]);
|
||||
|
||||
// Appearance is the window's, not the focused pane's. A remote kernel keeps
|
||||
// its own copy, and adopting that one repaints this window because the
|
||||
// reader changed tabs — so these three read and write the local pane only.
|
||||
const lookPort = useMemo(() => {
|
||||
const local = runtimes.find((rt) => !rt.host);
|
||||
return local ? hub.portFor(local) : null;
|
||||
}, [hub, runtimes]);
|
||||
|
||||
const reloadThemes = useCallback(() => {
|
||||
lookPort
|
||||
?.themes()
|
||||
.then((list) => setPack(list.find((p) => p.active) ?? null))
|
||||
.catch(() => setPack(null));
|
||||
}, [lookPort]);
|
||||
useEffect(reloadThemes, [reloadThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
lookPort
|
||||
?.appearance()
|
||||
.then((look) => {
|
||||
// The kernel's copy is the authority across machines; adopt reloads if
|
||||
// this window booted in the wrong language from a stale local cache.
|
||||
if (adoptLang(look.language)) return;
|
||||
setLook(look);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [lookPort]);
|
||||
|
||||
// The control moves now and the config catches up: a size or a colour that
|
||||
// waits on a round trip reads as a dead click. The kernel's answer is still
|
||||
// what is kept, since it clamps what the slider sent.
|
||||
const saveLook = useMemo(
|
||||
() => (lookPort ? trailing((next: Look) => lookPort.saveAppearance(next), setLook, fail) : null),
|
||||
[lookPort, fail],
|
||||
);
|
||||
const onLook = useCallback(
|
||||
(next: Look) => {
|
||||
setLook(next);
|
||||
saveLook?.(next);
|
||||
},
|
||||
[saveLook],
|
||||
);
|
||||
|
||||
const running = report.run === "running";
|
||||
// A pack carries a light and a dark set, so it is repainted with the scheme
|
||||
// rather than once at load: switching the OS to dark has to move both. The
|
||||
// running flag rides along because a pack's picture recedes while a turn is
|
||||
// in flight — the transition is in CSS, this only moves the target.
|
||||
useEffect(() => {
|
||||
const mq = matchMedia("(prefers-color-scheme: dark)");
|
||||
const paint = () => {
|
||||
const scheme = theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
|
||||
document.documentElement.dataset.theme = scheme;
|
||||
// The active pack is the same state shown by the appearance picker.
|
||||
// Applying `null` here made activation persist in the kernel while the
|
||||
// shell deliberately kept drawing the default palette, so a successful
|
||||
// click looked broken until forever. Keep the authored Studio layout,
|
||||
// but let the selected pack supply its documented colour/background
|
||||
// tokens immediately.
|
||||
applyThemePack(pack, scheme as "light" | "dark", running, contrast);
|
||||
// After the pack, never before: size and type are the reader's, and a
|
||||
// palette someone else authored does not get to overrule them.
|
||||
applyLook({ ...look, wallpaper: undefined }, running);
|
||||
};
|
||||
paint();
|
||||
mq.addEventListener("change", paint);
|
||||
localStorage.setItem("rx-theme", theme);
|
||||
return () => mq.removeEventListener("change", paint);
|
||||
}, [theme, pack, running, look, contrast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contrast) document.documentElement.dataset.contrast = contrast;
|
||||
else delete document.documentElement.dataset.contrast;
|
||||
localStorage.setItem("rx-contrast", contrast);
|
||||
if (weight) document.documentElement.dataset.weight = weight;
|
||||
else delete document.documentElement.dataset.weight;
|
||||
localStorage.setItem("rx-weight", weight);
|
||||
}, [contrast, weight]);
|
||||
|
||||
const { theme, setTheme, scheme, contrast, setContrast, weight, setWeight, look, onLook, pack, reloadThemes } =
|
||||
usePaint(hub, runtimes, running, fail);
|
||||
// A pane with no session file has never been written to — the empty one every
|
||||
// window opens with. Opening a conversation takes it over instead of parking
|
||||
// a blank column next to it.
|
||||
@@ -462,17 +368,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
[hub, reloadPanes],
|
||||
);
|
||||
|
||||
// Stable, or the sidebar's memo is defeated by its own handlers and a window
|
||||
// with a few hundred sessions rebuilds that whole tree on every repaint.
|
||||
const onFold = useCallback((root: string, shut: boolean) => {
|
||||
setFolded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (shut) next.add(root);
|
||||
else next.delete(root);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const adder = useAddWorkspace(hub, reloadTree, fail);
|
||||
// 每个窗口都有一个根 —— 没选过项目时那是它碰巧启动的地方。两者读起来一样,
|
||||
// 于是「从哪加项目」这句问题永远问不出口;只有内核说的 remembered 分得开。
|
||||
@@ -481,10 +376,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
useFoldAway("rail", setRail);
|
||||
|
||||
const onRailW = useCallback((w: number) => {
|
||||
setRailW(w);
|
||||
localStorage.setItem(RAIL.key, String(Math.round(w)));
|
||||
}, []);
|
||||
const onRailW = useCallback((w: number) => { setRailW(w); keepWidth(RAIL, w); }, []);
|
||||
const onDockW = useCallback((w: number) => { setDockW(w); keepWidth(DOCK, w); }, []);
|
||||
|
||||
// A webview has nowhere to put a new tab, so target="_blank" opens nothing at
|
||||
// all, and letting the link navigate in place would replace the session with
|
||||
@@ -583,9 +476,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// The folder a new session opens in is the one the switcher above names —
|
||||
// the window's, not whichever project happens to sit first in the tree.
|
||||
const newSessionRoot = activeWorkspace?.root;
|
||||
const sessionCount = tree.reduce((n, ws) => n + ws.sessions.filter((session) => !session.archived).length, 0);
|
||||
const archivedCount = tree.reduce((n, ws) => n + ws.sessions.filter((session) => session.archived).length, 0);
|
||||
const liveCount = liveIds(runtimes.map((rt) => rt.id)).length;
|
||||
|
||||
// The tab strip has nowhere to await: closing is the end of the gesture there,
|
||||
// so a refusal has to land in the error bar rather than in a caller.
|
||||
@@ -664,13 +554,13 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
className="app"
|
||||
data-run={report.run}
|
||||
data-rail={rail ? "on" : "off"}
|
||||
data-side={browser ? "on" : "off"}
|
||||
data-browser={browser ? "on" : "off"}
|
||||
data-focus={focus ? "true" : undefined}
|
||||
data-plan={report.status?.plan ? "on" : "off"}
|
||||
data-apv={report.status?.toolApprovalMode ?? "ask"}
|
||||
data-prefs={settings ? "" : undefined}
|
||||
data-tabs={runtimes.length > 1 ? "" : undefined}
|
||||
style={{ "--rail-open": `${railW}px`, "--side-open": "min(430px, 42vw)" } as CSSProperties}
|
||||
style={{ "--rail-open": `${railW}px`, "--dock-open": `${dockW}px` } as CSSProperties}
|
||||
>
|
||||
{ask && <RemoteAsk ask={ask} onAnswer={answerRemote} />}
|
||||
|
||||
@@ -689,7 +579,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
rail={rail}
|
||||
theme={theme}
|
||||
onRail={() => setRail((v) => !v)}
|
||||
onTheme={() => setTheme((v) => (v === "dark" ? "light" : "dark"))}
|
||||
onTheme={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
/>
|
||||
|
||||
{pack?.sky && <Sky />}
|
||||
@@ -700,144 +590,37 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onGo={showPrefs}
|
||||
onHome={hidePrefs}
|
||||
/>
|
||||
{/* 收起而不是卸载:卸掉就丢了侧栏的滚动位置、Inspector 的展开、当前选
|
||||
中的面板。inert 是「看不见就够不着」那一半 —— 只做视觉隐藏的话,
|
||||
屏幕上没有的栏还能被 Tab 走进去。 */}
|
||||
<div className="rail" inert={focus}>
|
||||
<div className="railscroll">
|
||||
<div className="studio-rail-head">
|
||||
<div className="studio-brand" aria-label="Reasonix Studio">
|
||||
<span className="studio-brand-mark" aria-hidden="true"><StudioIcon name="brand" /></span>
|
||||
<span className="studio-brand-word" aria-hidden="true">
|
||||
<b>reasoni<span className="studio-brand-accent">x</span></b>
|
||||
<small>studio</small>
|
||||
</span>
|
||||
<button className="studio-collapse" data-action="chrome.rail" onClick={() => setRail(false)} aria-label={t("收起工作区栏")} title={t("收起侧栏")}><StudioIcon name="panel" /></button>
|
||||
</div>
|
||||
<button
|
||||
className="studio-new-task"
|
||||
data-action="session.new"
|
||||
onClick={() => void openPane({ root: newSessionRoot }).catch(fail)}
|
||||
>
|
||||
<span aria-hidden="true"><StudioIcon name="plus" /></span>{t("新建会话")}<kbd>Alt N</kbd>
|
||||
</button>
|
||||
<button className="studio-search" data-action="workspace.search" onClick={() => document.querySelector<HTMLInputElement>(".wsfind input")?.focus()}>
|
||||
<span aria-hidden="true"><StudioIcon name="search" /></span>{t("搜索与快捷操作")}<kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
<div className="studio-section-label"><span>{t("已挂载工作区")}</span><small>{tree.length}</small><button data-action="workspace.add" onClick={() => adder.add()} aria-label={t("添加工作区")} title={t("选择本地文件夹")}><StudioIcon name="plus" /></button></div>
|
||||
{activeWorkspace && (
|
||||
<div className="studio-workspace-switcher">
|
||||
<button className="studio-current-workspace" data-action="workspace.switch" aria-haspopup="listbox" aria-expanded={showProjects} onClick={() => setShowProjects((v) => !v)} title={activeWorkspace.root}>
|
||||
<span aria-hidden="true"><StudioIcon name="folder" /></span>
|
||||
<span><b>{activeWorkspace.name}</b><small>{t("当前聚焦 · 已挂载 {n} 个", { n: tree.length })}</small></span>
|
||||
<i aria-hidden="true"><StudioIcon name="chevron" /></i>
|
||||
</button>
|
||||
{showProjects && (
|
||||
<div className="studio-workspace-pop" role="listbox" aria-label={t("已挂载工作区")}>
|
||||
<div className="studio-workspace-pop-head">
|
||||
<span>{t("选择聚焦工作区")}</span><small>{tree.length}</small>
|
||||
</div>
|
||||
<div className="studio-workspace-pop-list">
|
||||
{tree.map((workspace) => {
|
||||
const current = workspace.root === activeWorkspace.root;
|
||||
return (
|
||||
<button
|
||||
key={workspace.root}
|
||||
role="option"
|
||||
data-action="workspace.switch"
|
||||
data-value={workspace.root}
|
||||
aria-selected={current}
|
||||
title={workspace.root}
|
||||
onClick={() => {
|
||||
setShowProjects(false);
|
||||
if (current) return;
|
||||
const openRuntime = runtimes.find((runtime) => runtime.root === workspace.root);
|
||||
if (openRuntime) {
|
||||
focusPane(openRuntime.id);
|
||||
return;
|
||||
}
|
||||
void openPane({ root: workspace.root, sessionPath: workspace.sessions[0]?.path }).catch(fail);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true"><StudioIcon name="folder" /></span>
|
||||
<span><b>{workspace.name}</b><small>{t("{n} 会话", { n: workspace.sessions.length })}</small></span>
|
||||
{current && <i aria-hidden="true"><StudioIcon name="check" /></i>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button className="studio-workspace-pop-add" data-action="workspace.add" onClick={() => { setShowProjects(false); adder.add(); }}>
|
||||
<StudioIcon name="plus" /><span>{t("添加工作区")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-quicknav">
|
||||
<button data-action="settings.section" data-value="storage" onClick={() => showPrefs("storage")}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
|
||||
<button data-action="settings.section" data-value="ext" onClick={() => showPrefs("ext")}><span aria-hidden="true"><StudioIcon name="plug" /></span><b>{t("工具与集成")}</b><small>MCP · Skills</small></button>
|
||||
<button data-action="settings.section" data-value="remote" onClick={() => showPrefs("remote")}><span aria-hidden="true"><StudioIcon name="server" /></span><b>{t("运行环境")}</b><small>Local / SSH</small></button>
|
||||
</div>
|
||||
<div className="studio-section-label studio-session-label"><span>{t("会话")}</span></div>
|
||||
<div className="studio-session-filters" role="group" aria-label={t("会话范围")}>
|
||||
<div className="studio-session-segments">
|
||||
<button data-action="session.filter" data-value="all" aria-pressed={railScope === "all"} onClick={() => setRailScope("all")}><span>{t("全部")}</span><b>{sessionCount}</b></button>
|
||||
<button data-action="session.filter" data-value="live" aria-pressed={railScope === "live"} onClick={() => setRailScope("live")}><span>{t("进行中")}</span><b>{liveCount}</b></button>
|
||||
<button data-action="session.filter" data-value="pinned" aria-pressed={railScope === "pinned"} onClick={() => setRailScope("pinned")}><span>{t("置顶")}</span><b>{pinnedSessions.size}</b></button>
|
||||
<button data-action="session.filter" data-value="archived" aria-pressed={railScope === "archived"} onClick={() => setRailScope("archived")}><span>{t("归档")}</span><b>{archivedCount}</b></button>
|
||||
</div>
|
||||
<button className="studio-filter-search" data-action="workspace.search" onClick={() => document.querySelector<HTMLInputElement>(".wsfind input")?.focus()} aria-label={t("按名称筛选会话")}><StudioIcon name="search" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<RailSearch>
|
||||
<Workspaces
|
||||
hub={hub}
|
||||
tree={tree}
|
||||
runtimes={runtimes}
|
||||
active={active}
|
||||
folded={folded}
|
||||
onFold={onFold}
|
||||
reload={reloadTree}
|
||||
onOpen={openPane}
|
||||
onFocus={focusPane}
|
||||
onClose={closePanes}
|
||||
liveIds={liveIds}
|
||||
runs={runs}
|
||||
scope={railScope}
|
||||
pinned={pinnedSessions}
|
||||
onPin={togglePinnedSession}
|
||||
onPause={(runtimeId) => panePorts.get(runtimeId)?.cancel()}
|
||||
onArchive={archiveSession}
|
||||
onRename={renameSession}
|
||||
onError={fail}
|
||||
adder={adder}
|
||||
>
|
||||
{remotes ? (
|
||||
<RemoteHosts
|
||||
hub={hub}
|
||||
hosts={remotes}
|
||||
runtimes={runtimes}
|
||||
active={active}
|
||||
onOpen={openRemotePane}
|
||||
onFocus={focusPane}
|
||||
reload={reloadRemotes}
|
||||
trees={remoteTrees}
|
||||
reloadTrees={reloadRemoteTrees}
|
||||
onError={fail}
|
||||
/>
|
||||
) : null}
|
||||
</Workspaces>
|
||||
</RailSearch>
|
||||
</div>
|
||||
<div className="railfoot">
|
||||
<button className="studio-wallet" data-action="settings.section" data-value="usage" onClick={() => showPrefs("usage")}><span aria-hidden="true"><StudioIcon name="wallet" /></span><b>{t("钱包与用量")}</b>{report.wallet && <small>{report.wallet}</small>}</button>
|
||||
<div className="studio-user-foot">
|
||||
<AccountRow account={account} unread={accountUnread} onOpen={() => showPrefs("account")} />
|
||||
<span className="studio-workspace-kind">{t(account?.signedIn ? "个人工作空间" : "本地工作空间")}</span>
|
||||
<button className="studio-settings" data-action="chrome.settings" onClick={() => showPrefs()} aria-label={t("设置")}><StudioIcon name="settings" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Sidebar
|
||||
hub={hub}
|
||||
focus={focus}
|
||||
tree={tree}
|
||||
runtimes={runtimes}
|
||||
runs={runs}
|
||||
active={active}
|
||||
activeWorkspace={activeWorkspace}
|
||||
liveIds={liveIds}
|
||||
pinned={pinnedSessions}
|
||||
onPin={togglePinnedSession}
|
||||
remotes={remotes}
|
||||
remoteTrees={remoteTrees}
|
||||
reloadRemotes={reloadRemotes}
|
||||
reloadRemoteTrees={reloadRemoteTrees}
|
||||
reloadTree={reloadTree}
|
||||
adder={adder}
|
||||
onOpen={openPane}
|
||||
onOpenRemote={openRemotePane}
|
||||
onFocusPane={focusPane}
|
||||
onClosePanes={closePanes}
|
||||
onPause={(runtimeId) => void panePorts.get(runtimeId)?.cancel()}
|
||||
onArchive={archiveSession}
|
||||
onRename={renameSession}
|
||||
onCollapse={() => setRail(false)}
|
||||
account={account}
|
||||
accountUnread={accountUnread}
|
||||
wallet={report.wallet}
|
||||
onSettings={showPrefs}
|
||||
onError={fail}
|
||||
/>
|
||||
|
||||
<div className="main">
|
||||
<Gutter
|
||||
@@ -894,8 +677,11 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
setClaimed(true);
|
||||
}}
|
||||
onSettings={(section) => section ? showPrefs(section) : showPrefs()}
|
||||
theme={scheme}
|
||||
dockW={dockW}
|
||||
onDockW={onDockW}
|
||||
manualBrowser={rt.id === active && browser}
|
||||
onCloseManualBrowser={() => setBrowser(false)}
|
||||
onManualBrowser={setBrowser}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApplyNote } from "./Group";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { TrayPrefs, AgentPort, Appearance as Look, ThemePack } from "../port/port";
|
||||
import { MONO_FAMILIES, UI_FAMILIES, installed, readDefault, readSteps } from "./look";
|
||||
import { MONO_FAMILIES, UI_FAMILIES, installed, readSizeOf, readSteps } from "./look";
|
||||
import { STORAGE as LANG_KEY, t } from "../i18n";
|
||||
import { pct } from "../i18n/format";
|
||||
import { reason } from "../i18n/kernel";
|
||||
@@ -372,7 +372,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<span className="tx">{t("正文")}</span>
|
||||
<div className="seg" data-text role="group" aria-label={t("正文字号")}>
|
||||
{readSteps().map(([v, name]) => (
|
||||
<button key={v} data-action="appearance.reading-size" aria-pressed={(look.readSize || readDefault()) === v} onClick={() => set({ readSize: v })}>
|
||||
<button key={v} data-action="appearance.reading-size" aria-pressed={readSizeOf(look.readSize) === v} onClick={() => set({ readSize: v })}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ afterEach(cleanup);
|
||||
describe("BrowserPanel", () => {
|
||||
it("opens a typed address when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BrowserPanel scheme="light" onClose={vi.fn()} onExternal={vi.fn()} />);
|
||||
render(<BrowserPanel id="b0" scheme="light" onAddress={vi.fn()} onExternal={vi.fn()} />);
|
||||
|
||||
const address = screen.getByRole("textbox", { name: "网页地址" });
|
||||
await user.clear(address);
|
||||
@@ -21,7 +21,7 @@ describe("BrowserPanel", () => {
|
||||
|
||||
it("keeps invalid addresses in the field and explains the error", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BrowserPanel scheme="dark" onClose={vi.fn()} onExternal={vi.fn()} />);
|
||||
render(<BrowserPanel id="b0" scheme="dark" onAddress={vi.fn()} onExternal={vi.fn()} />);
|
||||
|
||||
const address = screen.getByRole("textbox", { name: "网页地址" });
|
||||
await user.clear(address);
|
||||
|
||||
@@ -37,12 +37,16 @@ function startPage(scheme: "light" | "dark"): string {
|
||||
}
|
||||
|
||||
interface ManualProps {
|
||||
onClose: () => void;
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
// What the tab should call this one. Several browsers all named 「浏览器」
|
||||
// name none of them, and only this panel knows where it has got to.
|
||||
onAddress: (id: string, host: string) => void;
|
||||
onExternal: (url: string) => void;
|
||||
scheme: "light" | "dark";
|
||||
}
|
||||
|
||||
export function ManualBrowserPanel({ onClose, onExternal, scheme }: ManualProps) {
|
||||
export function ManualBrowserPanel({ id, hidden, onAddress, onExternal, scheme }: ManualProps) {
|
||||
const [history, setHistory] = useState([START]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [input, setInput] = useState(START);
|
||||
@@ -52,6 +56,9 @@ export function ManualBrowserPanel({ onClose, onExternal, scheme }: ManualProps)
|
||||
const current = history[index] ?? START;
|
||||
const internal = current === START;
|
||||
const srcDoc = useMemo(() => startPage(scheme), [scheme]);
|
||||
useEffect(() => {
|
||||
onAddress(id, internal ? "" : new URL(current).hostname);
|
||||
}, [id, current, internal, onAddress]);
|
||||
|
||||
const visit = (value: string) => {
|
||||
const next = normalise(value);
|
||||
@@ -95,13 +102,7 @@ export function ManualBrowserPanel({ onClose, onExternal, scheme }: ManualProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="side studio-browser" aria-label={t("内置 Browser")}>
|
||||
<header className="studio-browser-head">
|
||||
<span><b>Browser</b><small>{t("内置浏览")}</small></span>
|
||||
<button type="button" data-action="browser.close" onClick={onClose} aria-label={t("关闭 Browser")} title={t("关闭")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
</header>
|
||||
<section className="studio-browser" hidden={hidden} aria-label={t("内置 Browser")}>
|
||||
<form className="studio-browser-toolbar" onSubmit={submit} data-action-submit="browser.navigate">
|
||||
<button type="button" data-action="browser.back" onClick={() => move(index - 1)} disabled={index === 0} aria-label={t("后退")} title={t("后退")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m9.5 3-5 5 5 5M5 8h7" /></svg>
|
||||
@@ -130,10 +131,18 @@ export function ManualBrowserPanel({ onClose, onExternal, scheme }: ManualProps)
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => setLoading(false)}
|
||||
/>
|
||||
{/* Not a hint at an icon somewhere else: a site that refuses to be framed
|
||||
shows the shell's own "refused to connect", and the way out has to be
|
||||
beside it. */}
|
||||
<footer className="studio-browser-note" data-loading={loading ? "" : undefined}>
|
||||
{loading ? t("正在打开网页…") : t("页面空白通常表示网站禁止嵌入,可使用右上角按钮在外部浏览器打开。")}
|
||||
<span>{loading ? t("正在打开网页…") : t("部分站点禁止被嵌入(Google、GitHub 等),这里会显示「拒绝连接」。")}</span>
|
||||
{!internal && (
|
||||
<button type="button" data-action="browser.external" onClick={() => onExternal(current)}>
|
||||
{t("在外部浏览器打开")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ interface Props {
|
||||
port: AgentPort;
|
||||
status: SessionStatus | null;
|
||||
running: boolean;
|
||||
// Text a card asked to be quoted, and a counter that makes the same text
|
||||
// twice two requests. Quoting the same reply again is an ordinary thing to
|
||||
// do, and comparing the string alone would drop the second one.
|
||||
quote?: { text: string; n: number };
|
||||
// Resolves false when the line never left, so what was typed comes back
|
||||
// rather than being lost to a refusal the user could not have prevented.
|
||||
// Bumped when something outside asks for the cursor — answering a plan card
|
||||
@@ -131,7 +135,7 @@ function releaseChip(c: Chip) {
|
||||
let chipSeq = 0;
|
||||
const chipId = () => `c${++chipSeq}`;
|
||||
|
||||
export function Composer({ port, status, running, focus, onSubmit, onChanged, onError, onSettings = () => {}, changeCount = 0 }: Props) {
|
||||
export function Composer({ port, status, running, quote, focus, onSubmit, onChanged, onError, onSettings = () => {}, changeCount = 0 }: Props) {
|
||||
const [branch, setBranch] = useState("");
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -174,6 +178,19 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
||||
caretRef.current = at;
|
||||
};
|
||||
|
||||
// A quote lands in the draft, never on the wire: it is material for what the
|
||||
// person is about to write, so it arrives unsent and with the caret after it.
|
||||
useEffect(() => {
|
||||
if (!quote?.n) return;
|
||||
setText((was) => {
|
||||
const block = quote.text.split("\n").map((line) => `> ${line}`).join("\n");
|
||||
const head = was.trim() ? `${was.replace(/\s+$/, "")}\n\n` : "";
|
||||
const next = `${head}${block}\n\n`;
|
||||
pending.current = next.length;
|
||||
return next;
|
||||
});
|
||||
}, [quote?.n]);
|
||||
|
||||
const menu = useCompletion(port, text, caret, (next, at) => {
|
||||
pending.current = at;
|
||||
type(next, at);
|
||||
@@ -364,6 +381,10 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
||||
});
|
||||
|
||||
const efforts = effortsFor(models, status?.modelRef);
|
||||
// Whether this model says anything about reasoning levels at all. The session
|
||||
// may still carry one from a model that did, and printing that would be the
|
||||
// composer answering for an endpoint that never spoke.
|
||||
const declared = efforts.length > 0;
|
||||
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
|
||||
// A model switch rebuilds the runtime kernel-side (~0.4s on a real session);
|
||||
// the other controls on this shelf may land immediately, and which is which
|
||||
@@ -704,11 +725,22 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
||||
align="end"
|
||||
menuClassName="studio-effort-menu"
|
||||
title={t("推理强度")}
|
||||
current={status.effort || "auto"}
|
||||
current={declared ? status.effort || "auto" : ""}
|
||||
pending={busy["effort"]}
|
||||
items={[
|
||||
{ value: "__effort-heading", label: t("推理强度"), right: modelLb, header: true },
|
||||
...(efforts.length ? efforts : ["auto"]).map((value) => ({
|
||||
// An endpoint that reported no levels has not said "none" —
|
||||
// it has said nothing. Rather than invent a rung, the one row
|
||||
// here goes where the capability is actually declared: a menu
|
||||
// of statements with nothing to press is a dead end.
|
||||
...(declared
|
||||
? []
|
||||
: [{
|
||||
value: "__effort-declare",
|
||||
label: t("去「模型来源」声明档位"),
|
||||
desc: t("这个端点没有报告推理档位。中转站通常不转发这项能力,在来源的「请求与能力」里可以声明。"),
|
||||
}]),
|
||||
...efforts.map((value) => ({
|
||||
value,
|
||||
label: effortLabel(value),
|
||||
meta: effortApi(value),
|
||||
@@ -717,10 +749,12 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
||||
strength: value === "disabled" ? 0 : ({ auto: 1, low: 1, medium: 2, high: 3, xhigh: 4, max: 4 } as Record<string, number>)[value.toLowerCase()] ?? 1,
|
||||
desc: effortDescription(value),
|
||||
})),
|
||||
{ value: "__effort-note", label: t("仅显示当前模型实际支持的档位。"), right: t("按模型生效"), header: true },
|
||||
...(declared
|
||||
? [{ value: "__effort-note", label: t("仅显示当前模型实际支持的档位。"), right: t("按模型生效"), header: true }]
|
||||
: []),
|
||||
]}
|
||||
onPick={(value) => change("effort", () => port.setEffort(value))}
|
||||
label={<><span>{effortReading(status.effort)}</span><StudioIcon name="down" /></>}
|
||||
onPick={(value) => value === "__effort-declare" ? onSettings("model") : change("effort", () => port.setEffort(value))}
|
||||
label={<><span>{declared ? effortReading(status.effort) : t("未声明")}</span><StudioIcon name="down" /></>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DecisionModels, DecisionModelsDraft } from "../port/decision";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
type Port = { decisionModels(): Promise<DecisionModels>; saveDecisionModels(v: DecisionModelsDraft): Promise<void> };
|
||||
|
||||
export function DecisionModelsPanel({ port, onChanged }: { port: Port; onChanged: () => void }) {
|
||||
const [value, setValue] = useState<DecisionModels | null>(null);
|
||||
const [typeSafeKey, setTypeSafeKey] = useState("");
|
||||
const [layaKey, setLayaKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => { port.decisionModels().then(setValue).catch((e) => setError(reason(e))); }, [port]);
|
||||
if (!value) return <p className="note">{error || t("正在读取…")}</p>;
|
||||
const updateTypeSafe = (patch: Partial<DecisionModels["typeSafe"]>) => setValue({ ...value, typeSafe: { ...value.typeSafe, ...patch } });
|
||||
const updateLaya = (patch: Partial<DecisionModels["laya"]>) => setValue({ ...value, laya: { ...value.laya, ...patch } });
|
||||
const save = async () => {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
await port.saveDecisionModels({ ...value, typeSafe: { ...value.typeSafe, apiKey: typeSafeKey }, laya: { ...value.laya, httpApiKey: layaKey } });
|
||||
setTypeSafeKey(""); setLayaKey(""); onChanged();
|
||||
setValue(await port.decisionModels());
|
||||
} catch (e) { setError(reason(e)); } finally { setBusy(false); }
|
||||
};
|
||||
return <>
|
||||
<div className="lrow"><span className="tx"><span className="lb">TypeSafe AI</span><span className="ds">{t("托管的 System One 决策接口")}</span></span></div>
|
||||
<label className="lrow"><span className="tx"><span className="lb">{t("接口地址")}</span></span><input data-action="decision.typesafe-url" value={value.typeSafe.baseUrl} onChange={(e) => updateTypeSafe({ baseUrl: e.target.value })} placeholder="https://api.typesafe.ai" /></label>
|
||||
<label className="lrow"><span className="tx"><span className="lb">{t("模型")}</span></span><input data-action="decision.typesafe-model" value={value.typeSafe.model} onChange={(e) => updateTypeSafe({ model: e.target.value })} placeholder="system-one" /></label>
|
||||
<label className="lrow"><span className="tx"><span className="lb">API Key</span><span className="ds">{value.typeSafe.hasKey ? t("已保存;留空则保持不变") : t("保存在本机凭据存储")}</span></span><input data-action="decision.typesafe-key" type="password" value={typeSafeKey} onChange={(e) => setTypeSafeKey(e.target.value)} /></label>
|
||||
<div className="lrow"><span className="tx"><span className="lb">Laya</span><span className="ds">{t("可使用本地 Python 模型或兼容 HTTP 网关")}</span></span><Switch data-action="decision.laya-local" on={value.laya.local} busy={busy} label={t("启用 Laya 本地模型")} onClick={() => updateLaya({ local: !value.laya.local })} /></div>
|
||||
<label className="lrow"><span className="tx"><span className="lb">{t("Python 路径")}</span></span><input data-action="decision.laya-python" value={value.laya.python} onChange={(e) => updateLaya({ python: e.target.value })} placeholder="python" /></label>
|
||||
<label className="lrow"><span className="tx"><span className="lb">{t("Laya 模型")}</span></span><input data-action="decision.laya-model" value={value.laya.model} onChange={(e) => updateLaya({ model: e.target.value })} placeholder="auto" /></label>
|
||||
<label className="lrow"><span className="tx"><span className="lb">{t("HTTP 网关")}</span></span><input data-action="decision.laya-url" value={value.laya.httpBaseUrl} onChange={(e) => updateLaya({ httpBaseUrl: e.target.value })} placeholder="http://127.0.0.1:8000" /></label>
|
||||
<label className="lrow"><span className="tx"><span className="lb">{t("网关 API Key")}</span><span className="ds">{value.laya.hasHttpKey ? t("已保存;留空则保持不变") : t("可选")}</span></span><input data-action="decision.laya-key" type="password" value={layaKey} onChange={(e) => setLayaKey(e.target.value)} /></label>
|
||||
{error && <p className="note" data-lvl="warn">{error}</p>}
|
||||
<div className="provider-toolbar"><span /><button className="act" data-action="decision.save" data-primary disabled={busy} onClick={save}>{busy ? t("正在保存…") : t("保存")}</button></div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from "react";
|
||||
import type { JobEntry } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { agentsIn } from "./panels/derive";
|
||||
import { Agents } from "./panels/Agents";
|
||||
import { Jobs } from "./panels/Jobs";
|
||||
import type { Task } from "./panels/Agents";
|
||||
|
||||
export type Deck = "" | "agents" | "jobs";
|
||||
|
||||
/** What is running on this session's behalf but is not in the transcript: work
|
||||
* handed to a sub-agent, and processes left running in the background. The
|
||||
* readings sit on the run rail and open the panel behind them, because a
|
||||
* session can be delegating four ways and otherwise say nothing about it. */
|
||||
export function DeckChips({ tasks, jobs, open, onOpen }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void }) {
|
||||
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
|
||||
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
|
||||
if (tasks.length === 0 && jobs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="studio-deckchips">
|
||||
{tasks.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-deckchip"
|
||||
data-action="deck.agents"
|
||||
data-live={liveAgents ? "" : undefined}
|
||||
aria-expanded={open === "agents"}
|
||||
onClick={() => onOpen((was) => (was === "agents" ? "" : "agents"))}
|
||||
>
|
||||
<StudioIcon name="branch" />
|
||||
<b>{liveAgents || agentsIn(tasks)}</b>
|
||||
<span>{t("子代理")}</span>
|
||||
</button>
|
||||
)}
|
||||
{jobs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-deckchip"
|
||||
data-action="deck.jobs"
|
||||
data-live={liveJobs ? "" : undefined}
|
||||
aria-expanded={open === "jobs"}
|
||||
onClick={() => onOpen((was) => (was === "jobs" ? "" : "jobs"))}
|
||||
>
|
||||
<StudioIcon name="play" />
|
||||
<b>{liveJobs || jobs.length}</b>
|
||||
<span>{t("后台任务")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The panel behind a chip. It opens upward out of the rail the reading sits
|
||||
* on, so the summary and its detail stay one object. */
|
||||
export function Deck({ open, tasks, jobs }: { open: Deck; tasks: Task[]; jobs: JobEntry[] }) {
|
||||
return (
|
||||
<div className="studio-deck" data-open={open ? "" : undefined}>
|
||||
{open === "agents" && <Agents tasks={tasks} />}
|
||||
{open === "jobs" && <Jobs jobs={jobs} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export interface Span {
|
||||
// 存,不进内核配置:换台机器、换块屏幕,判断本来就不同。
|
||||
export const RAIL: Span = { min: 176, max: 440, def: 264, key: "rx-rail-w", css: "--rail-open", col: "--rail-w" };
|
||||
export const SIDE: Span = { min: 244, max: 540, def: 316, key: "rx-side-w", css: "--side-open", col: "--side-w" };
|
||||
// The workbench beside the conversation. Wider than the rail because what sits
|
||||
// in it is a page someone is reading, not a list of names.
|
||||
export const DOCK: Span = { min: 320, max: 880, def: 560, key: "rx-dock-w", css: "--dock-open", col: "--dock-w" };
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
@@ -37,6 +40,12 @@ export function widthOf(span: Span): number {
|
||||
return Number.isFinite(saved) && saved > 0 ? clamp(saved, span.min, span.max) : span.def;
|
||||
}
|
||||
|
||||
/** The other half of widthOf: what a released drag commits, written beside the
|
||||
* read so one place spells the key. */
|
||||
export function keepWidth(span: Span, w: number) {
|
||||
localStorage.setItem(span.key, String(Math.round(w)));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
edge: "l" | "r";
|
||||
span: Span;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TreeWorkspace } from "../port/hub";
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon, type StudioIconName } from "./StudioIcon";
|
||||
|
||||
/** One thing the palette can do. Sessions are found by title; a command is
|
||||
* found by its own words and by the terms someone would reach for instead,
|
||||
* which is why keywords are carried rather than matched against the label. */
|
||||
export interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: StudioIconName;
|
||||
keywords: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
commands: Command[];
|
||||
tree: TreeWorkspace[];
|
||||
onOpenSession: (path: string) => void;
|
||||
}
|
||||
|
||||
interface Hit {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: StudioIconName;
|
||||
note?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const SESSION_LIMIT = 8;
|
||||
|
||||
export function Palette({ open, onClose, commands, tree, onOpenSession }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [at, setAt] = useState(0);
|
||||
const box = useRef<HTMLInputElement>(null);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
|
||||
// A palette opened on what the last one was looking for is a palette that
|
||||
// answers the wrong question; it starts empty every time.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
setAt(0);
|
||||
box.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = commands.filter((c) => !q || `${c.label} ${c.keywords}`.toLowerCase().includes(q));
|
||||
const sessions: Hit[] = [];
|
||||
for (const workspace of tree) {
|
||||
for (const session of workspace.sessions) {
|
||||
if (sessions.length >= SESSION_LIMIT) break;
|
||||
const title = session.title || session.name;
|
||||
if (q && !title.toLowerCase().includes(q)) continue;
|
||||
sessions.push({
|
||||
key: `s:${session.path}`,
|
||||
label: title,
|
||||
icon: "clock",
|
||||
note: workspace.name,
|
||||
run: () => onOpenSession(session.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ label: t("快捷操作"), hits: matched.map((c) => ({ key: `c:${c.id}`, label: c.label, icon: c.icon, note: t("操作"), run: c.run })) },
|
||||
{ label: t("会话"), hits: sessions },
|
||||
].filter((g) => g.hits.length > 0);
|
||||
}, [commands, tree, query, onOpenSession]);
|
||||
|
||||
const flat = useMemo(() => groups.flatMap((g) => g.hits), [groups]);
|
||||
// A cursor past the end of a shorter list selects nothing and Enter does
|
||||
// nothing, which reads as the palette having stopped working.
|
||||
const cursor = flat.length === 0 ? -1 : Math.min(at, flat.length - 1);
|
||||
|
||||
const pick = useCallback(
|
||||
(hit: Hit | undefined) => {
|
||||
if (!hit) return;
|
||||
onClose();
|
||||
hit.run();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursor < 0) return;
|
||||
list.current?.querySelectorAll<HTMLElement>(".palette-hit")[cursor]?.scrollIntoView({ block: "nearest" });
|
||||
}, [cursor]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="palette-veil" role="presentation" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="palette" role="dialog" aria-modal="true" aria-label={t("搜索与快捷操作")}>
|
||||
<label className="palette-input">
|
||||
<StudioIcon name="search" />
|
||||
<input
|
||||
ref={box}
|
||||
data-action-keydown="palette.search"
|
||||
aria-label={t("搜索会话或操作")}
|
||||
placeholder={t("搜索会话、工具、设置…")}
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setAt(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") return onClose();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
return pick(flat[cursor]);
|
||||
}
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
if (flat.length === 0) return;
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setAt((n) => (Math.min(n, flat.length - 1) + step + flat.length) % flat.length);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="palette-results" ref={list}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="palette-label">{group.label}</div>
|
||||
{group.hits.map((hit) => (
|
||||
<button
|
||||
key={hit.key}
|
||||
className="palette-hit"
|
||||
data-action="palette.pick"
|
||||
data-target={hit.key}
|
||||
aria-selected={flat[cursor]?.key === hit.key}
|
||||
onMouseMove={() => setAt(flat.findIndex((h) => h.key === hit.key))}
|
||||
onClick={() => pick(hit)}
|
||||
>
|
||||
<StudioIcon name={hit.icon} />
|
||||
<span>{hit.label}</span>
|
||||
{hit.note && <small>{hit.note}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{flat.length === 0 && <div className="palette-empty">{t("没有匹配的会话或操作")}</div>}
|
||||
</div>
|
||||
<div className="palette-help">{t("↑ ↓ 选择 · Enter 打开 · Esc 关闭")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from "react";
|
||||
import { money } from "../i18n/format";
|
||||
import { money, tokens as tokenCount } from "../i18n/format";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
import { hasPendingDecision, posture, runState } from "./decisions";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HttpError } from "../port/port";
|
||||
import type { AgentPort, ApprovalVerdict, Checkpoint, ContextBreakdown, JobEntry, McpEntry, Queue as QueueSnapshot, RewindScope, SessionStatus, WorkspaceChanges } from "../port/port";
|
||||
import type { AgentPort, ApprovalVerdict, Checkpoint, ContextBreakdown, JobEntry, McpEntry, Queue as QueueSnapshot, QueueItem, SessionStatus, WorkspaceChanges } from "../port/port";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
import type { TrajectoryRead } from "../port/wire";
|
||||
import { fromHistory, initialState, localId, quoteAmount, reduce } from "../state/session";
|
||||
import { currentStep, fromHistory, initialState, localId, quoteAmount, reduce, stepDone, stepLabel } from "../state/session";
|
||||
import { pairCheckpoints } from "../state/checkpoints";
|
||||
import { Deck as DeckPanel, DeckChips, type Deck } from "./DeckChips";
|
||||
import { Plan } from "./Plan";
|
||||
import { useReplyActions } from "./reply";
|
||||
import { useRewindActions } from "./rewind";
|
||||
import { initialTraj, reduceTraj } from "../state/trajectory";
|
||||
import { Transcript } from "./Transcript";
|
||||
import { Composer } from "./Composer";
|
||||
@@ -22,10 +26,12 @@ import { railOf } from "./panels/derive";
|
||||
import { ABSENT, accountOf, type Wallet } from "./wallet";
|
||||
import { swapping } from "./swap";
|
||||
import { PaneNav, type PaneView } from "./PaneNav";
|
||||
import { useRate } from "./num";
|
||||
import { useRate, useTrail } from "./num";
|
||||
import { Spark } from "./Spark";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { useDismiss } from "./dismiss";
|
||||
import { ContextSummaryCard } from "./ContextSummaryCard";
|
||||
import { DOCK, Gutter } from "./Gutter";
|
||||
import { Find } from "./Find";
|
||||
import { useFind } from "./usefind";
|
||||
import { RunAnalysis } from "./RunAnalysis";
|
||||
@@ -36,7 +42,6 @@ import { RMark } from "./RMark";
|
||||
import { speedOf } from "./speed";
|
||||
import { RuntimeBar } from "./RuntimeBar";
|
||||
|
||||
const BROWSER_DOCK = "rx-browser-dock";
|
||||
|
||||
// PaneReport is what the window's own chrome needs from whichever pane has
|
||||
// focus: everything else about a session stays inside the pane that owns it.
|
||||
@@ -59,6 +64,15 @@ export interface PaneReport {
|
||||
// changed prop to the rail below it.
|
||||
const NO_JOBS: JobEntry[] = [];
|
||||
|
||||
const totalsOf = (st: SessionStatus) => ({
|
||||
kind: "__totals",
|
||||
hit: st.cacheHit,
|
||||
miss: st.cacheMiss,
|
||||
cost: quoteAmount(st.sessionCostQuote),
|
||||
coverage: st.sessionCostQuote?.coverage,
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
rt: RuntimeView;
|
||||
@@ -87,11 +101,16 @@ interface Props {
|
||||
needsProject: boolean;
|
||||
onOpenProject: () => void;
|
||||
onKeepHere: () => void;
|
||||
// A prop, not a document read: this pane is memoised past an attribute flip.
|
||||
theme: string;
|
||||
// Rides on `.app`: that is where the divider writes while a drag is in flight.
|
||||
dockW: number;
|
||||
onDockW: (w: number) => void;
|
||||
manualBrowser?: boolean;
|
||||
onCloseManualBrowser?: () => void;
|
||||
onManualBrowser?: (on: boolean) => void;
|
||||
}
|
||||
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, manualBrowser = false, onCloseManualBrowser }: Props) {
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, theme, dockW, onDockW, manualBrowser = false, onManualBrowser }: Props) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||
@@ -106,11 +125,12 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const [queue, setQueue] = useState<QueueSnapshot | null>(null);
|
||||
const [slots, setSlots] = useState<Record<string, string>>({});
|
||||
const pages = useBrowserTabs(port, s.browserTabsMoved);
|
||||
const [dock, setDock] = useState(() => localStorage.getItem(BROWSER_DOCK) === "on");
|
||||
const [surfaces, setSurfaces] = useState(0);
|
||||
// Analysis owns the whole working canvas. A docked browser and the composer
|
||||
// are useful while talking to the agent, but both compete with the timeline
|
||||
// for exactly the horizontal/vertical space the analysis view explains.
|
||||
const docked = dock && (pages.length > 0 || manualBrowser) && tab === "flow";
|
||||
const docked = manualBrowser && tab === "flow";
|
||||
const workbench = docked || tab === "browser";
|
||||
const [meterOpen, setMeterOpen] = useState(false);
|
||||
const meterRef = useRef<HTMLDivElement>(null);
|
||||
const closeMeter = useCallback(() => setMeterOpen(false), []);
|
||||
@@ -119,6 +139,14 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
|
||||
// follows the deltas themselves, and expires rather than being re-derived.
|
||||
const tps = useRate(s.outWindow, s.running);
|
||||
// The shape of the last minute, kept while a turn runs. A number alone says
|
||||
// how fast it is now; the line says whether it is climbing, stalling or
|
||||
// arriving in bursts, which is the question someone watching a run has.
|
||||
const trail = useTrail(tps, s.running);
|
||||
// What this turn has actually put on the wire: input counted whether or not
|
||||
// the prefix cache took it, and output as it comes back.
|
||||
const sent = s.metrics.hit + s.metrics.miss;
|
||||
const received = s.metrics.out;
|
||||
const speed = useMemo(() => speedOf(traj.rows), [traj.rows]);
|
||||
|
||||
const reloadMcp = useCallback(() => {
|
||||
@@ -205,14 +233,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
port.status().then((st) => {
|
||||
if (!alive) return;
|
||||
setStatus(st);
|
||||
dispatch({
|
||||
kind: "__totals",
|
||||
hit: st.cacheHit,
|
||||
miss: st.cacheMiss,
|
||||
cost: quoteAmount(st.sessionCostQuote),
|
||||
coverage: st.sessionCostQuote?.coverage,
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
} as never);
|
||||
dispatch(totalsOf(st) as never);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -262,39 +283,13 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
});
|
||||
port.status().then((st) => {
|
||||
applyStatus(st);
|
||||
dispatch({
|
||||
kind: "__totals",
|
||||
hit: st.cacheHit,
|
||||
miss: st.cacheMiss,
|
||||
cost: quoteAmount(st.sessionCostQuote),
|
||||
coverage: st.sessionCostQuote?.coverage,
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
} as never);
|
||||
dispatch(totalsOf(st) as never);
|
||||
});
|
||||
refreshWallet();
|
||||
onSessionChanged();
|
||||
}, [port, applyStatus, refreshWallet, onSessionChanged, replayTrajectory]);
|
||||
|
||||
// A rewind rewrites the transcript and the files under it, so the whole
|
||||
// session is re-read rather than patched — the same treatment a session
|
||||
// switch gets, for the same reason.
|
||||
const onPrepareRewind = useCallback((turn: number, scope: RewindScope) => port.prepareRewind(turn, scope), [port]);
|
||||
const onCommitRewind = useCallback(
|
||||
async (planId: string) => {
|
||||
const result = await port.commitRewind(planId);
|
||||
reloadSession();
|
||||
return result;
|
||||
},
|
||||
[port, reloadSession],
|
||||
);
|
||||
const onUndoRewind = useCallback((transactionId: string) => port.undoRewind(transactionId).then(reloadSession), [port, reloadSession]);
|
||||
// Reverting one file touches disk but not the transcript, so unlike a rewind
|
||||
// it does not reload the session — only the file changes.
|
||||
const onPrepareFileRevert = useCallback((path: string) => port.prepareFileRevert(path), [port]);
|
||||
const onCommitFileRevert = useCallback(
|
||||
(planId: string, resolution?: string) => port.commitFileRevert(planId, resolution),
|
||||
[port],
|
||||
);
|
||||
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession);
|
||||
|
||||
// Both of these read only the user and tool cards, so they key off the
|
||||
// revision rather than the items array: a streamed answer leaves every card
|
||||
@@ -304,6 +299,15 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
const paired = useMemo(() => pairCheckpoints(s.items, checkpoints), [s.revision, checkpoints]);
|
||||
const rail = useMemo(() => railOf(s.items, s.executions), [s.revision, s.executions]);
|
||||
|
||||
// Sub-agents and background processes: built as panels, never drawn.
|
||||
const [deck, setDeck] = useState<Deck>("");
|
||||
// The list belongs above the line that narrates the turn, because that is
|
||||
// what it is about. Plan was built and only ever mounted inside an inspector
|
||||
// nothing renders, which is why it had never been seen.
|
||||
const todoOpen = s.plan.length > 0 && tab === "flow";
|
||||
const [todoShown, setTodoShown] = useState(false);
|
||||
const jobs = status?.jobs ?? NO_JOBS;
|
||||
const counts = useMemo(() => {
|
||||
let steps = 0;
|
||||
let steer = 0;
|
||||
@@ -428,8 +432,33 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const onQueueRefresh = useCallback((id: string) => void port.refreshQueued(id).catch(fail), [port, fail]);
|
||||
const onQueuePause = useCallback((on: boolean) => void port.setQueuePaused(on).catch(fail), [port, fail]);
|
||||
const onQueueRead = useCallback((id: string) => port.readQueued(id), [port]);
|
||||
// "Send now" means the only thing it can while a turn holds the session: end
|
||||
// that turn, and the queue dispatches this line as the next one. Guidance the
|
||||
// running turn already accepted has to leave it first — a turn that ends
|
||||
// without reading an accepted steer parks it as uncertain and pauses the
|
||||
// whole queue, which is the opposite of sending it.
|
||||
const onQueueSendNow = useCallback(
|
||||
async (item: QueueItem) => {
|
||||
try {
|
||||
if (item.state === "steer_accepted") {
|
||||
const text = await port.readQueued(item.id);
|
||||
await port.cancelQueued(item.id);
|
||||
dispatch({ kind: "__unsent", id: item.id } as never);
|
||||
const id = localId();
|
||||
dispatch({ kind: "__user", text, pending: false, id } as never);
|
||||
const again = await port.queueFollowup(text);
|
||||
if (again?.itemId) dispatch({ kind: "__queued", id, itemId: again.itemId, queued: "followup" } as never);
|
||||
}
|
||||
await port.cancel();
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[port, fail],
|
||||
);
|
||||
// The panel knows the entry, never the row the composer minted for it, so
|
||||
// taking one back here has to name it the way the kernel does.
|
||||
// taking one back here has to name it the way the kernel does. __unsent takes
|
||||
// either name, and the queue is now the only place a waiting line is shown.
|
||||
const onQueueCancel = useCallback(
|
||||
(itemId: string) => {
|
||||
port
|
||||
@@ -440,19 +469,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
[port, fail],
|
||||
);
|
||||
|
||||
// Cancelling is only meaningful before the turn reads the line. After that
|
||||
// the kernel refuses and says so, and the row stops calling itself queued on
|
||||
// its own — the steer event that made it too late is also what clears it.
|
||||
const onCancelQueued = useCallback(
|
||||
(rowId: string, itemId: string) => {
|
||||
port
|
||||
.cancelQueued(itemId)
|
||||
.then(() => dispatch({ kind: "__unsent", id: rowId } as never))
|
||||
.catch(fail);
|
||||
},
|
||||
[port, fail],
|
||||
);
|
||||
|
||||
// The kernel refuses a submit it cannot start with a code, not a sentence:
|
||||
// the words are fine, the timing is not. Queueing them is what that code
|
||||
// asks for, and showing anyone the refusal instead is how a race between
|
||||
@@ -572,12 +588,25 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
// Every route into a view goes through one place, so the menu never grows a
|
||||
// motion language of its own: it says which view, and this says how a pane
|
||||
// changes from one to another.
|
||||
const showView = useCallback((to: PaneView) => swapping(() => setTab(to), "tab"), []);
|
||||
// The workbench is the one view that sits beside the conversation rather than
|
||||
// over it, because it is read against what was said. Picking it from the nav
|
||||
// opens the same dock the globe does; there was no reason for one control to
|
||||
// take the conversation away and the other to keep it.
|
||||
const showView = useCallback(
|
||||
(to: PaneView) => {
|
||||
if (to === "browser") {
|
||||
onManualBrowser?.(true);
|
||||
swapping(() => setTab("flow"), "tab");
|
||||
return;
|
||||
}
|
||||
if (to === "flow") onManualBrowser?.(false);
|
||||
swapping(() => setTab(to), "tab");
|
||||
},
|
||||
[onManualBrowser],
|
||||
);
|
||||
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]));
|
||||
|
||||
useEffect(() => {
|
||||
if (manualBrowser) setTab("browser");
|
||||
}, [manualBrowser]);
|
||||
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running: s.running, model: status?.label, submit, onSettings, onRunDetail: () => showView("analysis"), onError: fail });
|
||||
|
||||
// Where the bottom is moves as blocks mount under it, so this only asks the
|
||||
// transcript to follow again and lets it scroll itself into place.
|
||||
@@ -624,23 +653,20 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
onFocusCapture={active ? undefined : onFocus}
|
||||
>
|
||||
<PaneNav
|
||||
view={tab}
|
||||
view={docked ? "browser" : tab}
|
||||
onPick={showView}
|
||||
rows={traj.rows.length}
|
||||
pages={pages.length + (manualBrowser ? 1 : 0) + 1}
|
||||
dock={dock}
|
||||
onDock={() => setDock((on) => {
|
||||
localStorage.setItem(BROWSER_DOCK, on ? "off" : "on");
|
||||
return !on;
|
||||
})}
|
||||
surfaces={surfaces}
|
||||
/>
|
||||
|
||||
<Find find={find} />
|
||||
|
||||
<div className="pbody" data-dock={docked ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
|
||||
<div className="pbody" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
|
||||
<div className="pviews">
|
||||
|
||||
<Transcript
|
||||
reply={reply}
|
||||
onResend={onResend}
|
||||
items={s.items}
|
||||
entering={s.entranceOwed}
|
||||
onEntered={(ids) => dispatch({ kind: "__entered", ids } as never)}
|
||||
@@ -659,7 +685,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
onPlan={onPlan}
|
||||
onAnswer={onAnswer}
|
||||
onForget={onForget}
|
||||
onCancelQueued={onCancelQueued}
|
||||
onExtInvoke={onExtInvoke}
|
||||
onExtSubmit={onExtSubmit}
|
||||
checkpoints={paired}
|
||||
@@ -684,20 +709,29 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(docked || tab === "browser") && (
|
||||
<div className="scroll" data-pane="browser">
|
||||
{/* Kept mounted rather than switched on: the open files, the browsers and
|
||||
where each had got to are what a glance at the conversation must not
|
||||
cost. */}
|
||||
{/* Mounted shut as well as open: shut is where it becomes the tab that
|
||||
brings the browser back, which is the only affordance left once the
|
||||
column has no width. */}
|
||||
{tab === "flow" && (
|
||||
<Gutter edge="r" span={DOCK} width={dockW} label={t("调整浏览器宽度")} open={docked}
|
||||
onWidth={onDockW} onOpen={(on) => onManualBrowser?.(on)} />
|
||||
)}
|
||||
<div className="scroll" data-pane="browser" hidden={tab === "analysis"}>
|
||||
<WorkbenchPanel
|
||||
port={port}
|
||||
tabs={pages}
|
||||
manual={manualBrowser}
|
||||
shown={visible}
|
||||
scheme={document.documentElement.dataset.theme === "light" ? "light" : "dark"}
|
||||
shown={visible && workbench}
|
||||
onSurfaces={setSurfaces}
|
||||
scheme={theme === "light" ? "light" : "dark"}
|
||||
changes={tree?.changes ?? []}
|
||||
onCloseManual={onCloseManualBrowser ?? (() => {})}
|
||||
onCloseManual={() => onManualBrowser?.(false)}
|
||||
onExternal={(url) => void port.openExternal(url).catch(fail)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keep the composer mounted so a half-written prompt survives a visit to
|
||||
analysis; hidden removes it from layout without throwing its state
|
||||
@@ -709,23 +743,60 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
<span className="glowring" aria-hidden="true">
|
||||
<i />
|
||||
</span>
|
||||
{/* Everything that stacks above the input box shares one ceiling. Each
|
||||
of these was bounded on its own or not at all, and "not at all" is
|
||||
what let the queue grow until the transcript had no height left —
|
||||
the next child to do it would have been a different one. */}
|
||||
{/* This is the state of the input surface itself, so it follows the
|
||||
textarea's left edge. Putting it inside composeaux centred it to
|
||||
the narrower queue rail and made it appear to float inward. */}
|
||||
{(s.running || blocked) && (
|
||||
<div className="studio-runstate" role="status" aria-live="polite" data-waiting={blocked ? "" : undefined}>
|
||||
{/* Everything stacked above the input box shares one ceiling, so no
|
||||
child of this region may grow without bound. */}
|
||||
{todoOpen && (
|
||||
<div className="studio-todo" data-open={todoShown ? "" : undefined}>
|
||||
{/* One line by default. The composer already carries the run line,
|
||||
the outbox and its own toolbar; a plan opened over all of them
|
||||
pushes the box a person types in off the bottom of a laptop. */}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-todo-sum"
|
||||
data-action="plan.fold"
|
||||
aria-expanded={todoShown}
|
||||
onClick={() => setTodoShown((on) => !on)}
|
||||
>
|
||||
<StudioIcon name="list" />
|
||||
<b>{t("计划")}</b>
|
||||
<span>{stepLabel(s.plan[currentStep(s.plan)] ?? s.plan[0])}</span>
|
||||
<small>{s.plan.filter(stepDone).length}/{s.plan.length}</small>
|
||||
<StudioIcon name="down" className="studio-todo-fold" />
|
||||
</button>
|
||||
{todoShown && <div className="studio-todo-body"><Plan steps={s.plan} /></div>}
|
||||
</div>
|
||||
)}
|
||||
{tab === "flow" && (
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={s.running || blocked ? undefined : ""}
|
||||
>
|
||||
<RMark />
|
||||
<span>{t(s.doing || "运行中")}</span>
|
||||
{sent + received > 0 && (
|
||||
<span className="studio-runtokens" title={t("本轮已发送 / 已接收的 token")}>
|
||||
<span data-io="up">
|
||||
<StudioIcon name="arrow" />
|
||||
<b>{tokenCount(sent)}</b>
|
||||
</span>
|
||||
<span data-io="down">
|
||||
<StudioIcon name="arrow" />
|
||||
<b>{tokenCount(received)}</b>
|
||||
</span>
|
||||
<small>tokens</small>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="composeaux">
|
||||
<Queue
|
||||
queue={queue}
|
||||
running={s.running}
|
||||
onRead={onQueueRead}
|
||||
onSendNow={onQueueSendNow}
|
||||
onEdit={onQueueEdit}
|
||||
onMove={onQueueMove}
|
||||
onCancel={onQueueCancel}
|
||||
@@ -750,8 +821,12 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Composer port={port} status={status} running={s.running} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} onSettings={onSettings} changeCount={tree?.repo ? tree.changes.length : 0} />
|
||||
<Composer port={port} status={status} running={s.running} quote={quote} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} onSettings={onSettings} changeCount={tree?.repo ? tree.changes.length : 0} />
|
||||
{/* Above the rail rather than over the transcript: this is about the
|
||||
run, and the run's own readings are the row it belongs to. */}
|
||||
<DeckPanel open={deck} tasks={rail.tasks} jobs={jobs} />
|
||||
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
|
||||
<DeckChips tasks={rail.tasks} jobs={jobs} open={deck} onOpen={setDeck} />
|
||||
<div className="studio-speed-anchor">
|
||||
<button
|
||||
className="studio-meter-static studio-meter-speed"
|
||||
@@ -759,7 +834,9 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
aria-describedby="studio-speed-detail"
|
||||
aria-label={t("查看生成速度详情")}
|
||||
>
|
||||
<StudioIcon name="gauge" /><b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={s.running ? "" : undefined} aria-hidden="true" />
|
||||
<StudioIcon name="gauge" />
|
||||
<Spark points={trail} w={44} h={13} />
|
||||
<b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={s.running ? "" : undefined} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="studio-speed-detail" id="studio-speed-detail" role="tooltip">
|
||||
<header><b>{t("生成速度")}</b><small>{s.running ? t("实时更新") : t("最近一轮")}</small></header>
|
||||
|
||||
@@ -6,24 +6,33 @@ import { PaneNav } from "./PaneNav";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const draw = (rows = 3, pages = 0) => {
|
||||
const draw = (rows = 3, surfaces = 0) => {
|
||||
const onPick = vi.fn();
|
||||
render(<PaneNav view="flow" onPick={onPick} rows={rows} pages={pages} dock={false} onDock={() => {}} />);
|
||||
render(<PaneNav view="flow" onPick={onPick} rows={rows} surfaces={surfaces} />);
|
||||
return onPick;
|
||||
};
|
||||
|
||||
describe("pane navigation", () => {
|
||||
it("keeps only conversation and the readable run analysis", () => {
|
||||
it("keeps only conversation, the readable run analysis and the workbench", () => {
|
||||
draw();
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["对话", "运行分析"]);
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["对话", "运行分析", "工作台"]);
|
||||
expect(screen.queryByText("任务")).toBeNull();
|
||||
expect(screen.queryByText("运行详情")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens analysis directly and adds the workbench when it has a surface", () => {
|
||||
it("opens analysis directly", () => {
|
||||
const onPick = draw(2, 1);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "运行分析" }));
|
||||
expect(onPick).toHaveBeenCalledWith("analysis");
|
||||
expect(screen.getByRole("tab", { name: /工作台/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
// The count said how many surfaces the workbench holds while carrying a +1
|
||||
// that kept the tab drawn, so one open browser was announced as two.
|
||||
it("counts the surfaces the workbench strip holds, and nothing else", () => {
|
||||
draw(0, 0);
|
||||
expect(screen.getByRole("tab", { name: "工作台" }).querySelector(".n")).toBeNull();
|
||||
cleanup();
|
||||
draw(0, 1);
|
||||
expect(screen.getByRole("tab", { name: /工作台/ }).querySelector(".n")?.textContent).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,16 @@ export type PaneView = "flow" | "analysis" | "browser";
|
||||
* purposes; it does not own them, and it never holds a selection of its own —
|
||||
* a second piece of state saying which detail is open is a second answer to a
|
||||
* question the pane has already answered. */
|
||||
export function PaneNav({ view, onPick, rows, pages, dock, onDock }: {
|
||||
export function PaneNav({ view, onPick, rows, surfaces }: {
|
||||
view: PaneView;
|
||||
onPick: (to: PaneView) => void;
|
||||
rows: number;
|
||||
// The agent's open browser pages. The tab exists only while there is one.
|
||||
pages: number;
|
||||
// Whether those pages sit beside the conversation, and the switch for it.
|
||||
dock: boolean;
|
||||
onDock: () => void;
|
||||
// Browsers and files open on the workbench's own tab strip. The workbench is
|
||||
// always reachable — the workspace tree lives there — so this is the count
|
||||
// beside the tab, never whether to draw it. It counts what that strip holds:
|
||||
// a number naming one thing and standing for another is what put a 2 over a
|
||||
// strip with one tab on it.
|
||||
surfaces: number;
|
||||
}) {
|
||||
const bar = useRef<HTMLDivElement>(null);
|
||||
// Read at render: t() answers out of a dictionary boot() installs, and a
|
||||
@@ -44,26 +45,13 @@ export function PaneNav({ view, onPick, rows, pages, dock, onDock }: {
|
||||
{name.analysis}
|
||||
</button>
|
||||
)}
|
||||
{pages > 0 && (
|
||||
<button
|
||||
className="tab" role="tab" data-action="pane.view" data-value="browser"
|
||||
aria-selected={view === "browser"} onClick={() => onPick("browser")}
|
||||
>
|
||||
{name.browser}
|
||||
<span className="n">{pages}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Beside, not instead: watching the agent browse and reading what it
|
||||
says are one activity. Drawn only where there is a page for it. */}
|
||||
{pages > 0 && (
|
||||
<button
|
||||
className="tab dock" data-action="pane.dock" data-value={dock ? "off" : "on"}
|
||||
aria-pressed={dock} title={t("并排显示浏览器")} aria-label={t("并排显示浏览器")}
|
||||
onClick={onDock}
|
||||
>
|
||||
⿲
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="tab" role="tab" data-action="pane.view" data-value="browser"
|
||||
aria-selected={view === "browser"} onClick={() => onPick("browser")}
|
||||
>
|
||||
{name.browser}
|
||||
{surfaces > 0 && <span className="n">{surfaces}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,13 @@ export function Plan({ steps, shownElsewhere }: { steps: PlanStep[]; shownElsewh
|
||||
data-now={i === now ? "" : undefined}
|
||||
style={{ animationDelay: `${Math.min(i, 6) * 34}ms`, marginInlineStart: indent(st) }}
|
||||
>
|
||||
<span className="b">{stepDone(st) ? "✓" : i + 1}</span>
|
||||
{/* The state is the shape: a dotted ring is not started, a
|
||||
turning arc is in hand, a filled disc is done. It replaces the
|
||||
ordinal, which was the number the model then cited back. */}
|
||||
<span
|
||||
className="b"
|
||||
aria-label={stepDone(st) ? t("已完成") : i === now ? t("进行中") : t("未开始")}
|
||||
/>
|
||||
{/* The strike is painted as a background so it can be drawn rather
|
||||
than appear, and an inline box is what makes it repeat per line
|
||||
— on the flex item itself a wrapped step gets one line struck. */}
|
||||
|
||||
@@ -3,21 +3,34 @@ import type { Queue as QueueSnapshot, QueueItem } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { Overflow } from "./Overflow";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
interface Props {
|
||||
queue: QueueSnapshot | null;
|
||||
// A turn holding the session is the whole reason these are waiting, and it is
|
||||
// what "send now" has to interrupt. Without it the action is meaningless: an
|
||||
// idle queue dispatches on its own.
|
||||
running: boolean;
|
||||
onRead: (id: string) => Promise<string>;
|
||||
onEdit: (id: string, text: string) => void;
|
||||
onMove: (id: string, to: number) => void;
|
||||
onCancel: (id: string) => void;
|
||||
onSendNow: (item: QueueItem) => void;
|
||||
onRetry: (id: string) => void;
|
||||
onRefresh: (id: string) => void;
|
||||
onPause: (paused: boolean) => void;
|
||||
}
|
||||
|
||||
// What each state means for the row's own affordances. A consumed entry is
|
||||
// history the kernel has not swept yet: it is past taking back.
|
||||
const settled = (s: QueueItem["state"]) => s === "steer_consumed" || s === "running";
|
||||
// The kernel has taken the line: it is in the transcript now, and the entry is
|
||||
// swept only when the whole turn ends. This panel is what has not happened yet,
|
||||
// so a taken entry leaves it rather than standing there inert as a second copy.
|
||||
const taken = (s: QueueItem["state"]) => s === "steer_consumed" || s === "running";
|
||||
|
||||
// "To send" is what the person said and has not sent yet. A host continuation —
|
||||
// a finished background job asking the session to pick the work back up — is
|
||||
// not theirs and belongs with the work it came from. It appears here only when
|
||||
// it needs a decision, which is the one case nobody else can make for them.
|
||||
const theirs = (it: QueueItem) => it.origin !== "host" || it.state === "blocked" || it.state === "uncertain";
|
||||
|
||||
// Each arm calls t() with its own literal: the catalogue is built by reading
|
||||
// these call sites, and a table of strings looked up later is invisible to it —
|
||||
@@ -29,10 +42,6 @@ function label(it: QueueItem): string {
|
||||
switch (it.state) {
|
||||
case "steer_accepted":
|
||||
return t("插话已收");
|
||||
case "steer_consumed":
|
||||
return t("已送入");
|
||||
case "running":
|
||||
return t("进行中");
|
||||
case "blocked":
|
||||
return t("受阻");
|
||||
case "uncertain":
|
||||
@@ -47,10 +56,7 @@ function label(it: QueueItem): string {
|
||||
function tone(state: QueueItem["state"]): string | undefined {
|
||||
switch (state) {
|
||||
case "steer_accepted":
|
||||
case "running":
|
||||
return "accent";
|
||||
case "steer_consumed":
|
||||
return "ok";
|
||||
case "blocked":
|
||||
case "uncertain":
|
||||
return "warn";
|
||||
@@ -70,7 +76,7 @@ function size(n: number): string {
|
||||
}
|
||||
const fill = (n: number, max: number) => (max > 0 ? `${Math.min(100, Math.round((n / max) * 100))}%` : "0%");
|
||||
|
||||
export function Queue({ queue, onRead, onEdit, onMove, onCancel, onRetry, onRefresh, onPause }: Props) {
|
||||
export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSendNow, onRetry, onRefresh, onPause }: Props) {
|
||||
const [editing, setEditing] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
// Which row could not be read back, and why. Not a draft: the editor stays
|
||||
@@ -114,8 +120,9 @@ export function Queue({ queue, onRead, onEdit, onMove, onCancel, onRetry, onRefr
|
||||
// queue policy; drawing a full strip for it above an empty composer makes it
|
||||
// look like a stuck task. If a new item arrives while held, the strip returns
|
||||
// with both the item and the action needed to release it.
|
||||
if (!queue || queue.items.length === 0) return null;
|
||||
const items = queue.items;
|
||||
if (!queue) return null;
|
||||
const items = queue.items.filter((it) => !taken(it.state) && theirs(it));
|
||||
if (items.length === 0) return null;
|
||||
const cap = queue.capacity;
|
||||
const fullItems = cap.maxItems > 0 && cap.items >= cap.maxItems;
|
||||
const fullBytes = cap.maxBytes > 0 && cap.bytes >= cap.maxBytes;
|
||||
@@ -134,7 +141,9 @@ export function Queue({ queue, onRead, onEdit, onMove, onCancel, onRetry, onRefr
|
||||
? t("会话暂时只读,消息已为你保留")
|
||||
: queue.paused
|
||||
? t("发送已暂停")
|
||||
: t("当前回复结束后发送")}
|
||||
: running
|
||||
? t("尚未送达,可以改,也可以取回")
|
||||
: t("正在送出…")}
|
||||
</span>
|
||||
{queue.paused && <span className="qflag" data-hold="">{t("已暂停")}</span>}
|
||||
{queue.readonly && <span className="qflag" data-ro="">{t("只读")}</span>}
|
||||
@@ -164,7 +173,7 @@ export function Queue({ queue, onRead, onEdit, onMove, onCancel, onRetry, onRefr
|
||||
|
||||
<div className="qitems">
|
||||
{items.map((it, i) => {
|
||||
const live = !settled(it.state) && !queue.readonly && editing !== it.id;
|
||||
const live = !queue.readonly && editing !== it.id;
|
||||
return (
|
||||
<div key={it.id} className="qi" data-state={it.state}>
|
||||
{/* The chip is the answer to "did that land". Its wording says
|
||||
@@ -210,12 +219,30 @@ export function Queue({ queue, onRead, onEdit, onMove, onCancel, onRetry, onRefr
|
||||
{live && (
|
||||
<span className="qacts">
|
||||
{items.length > 1 && <>
|
||||
<button data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>↑</button>
|
||||
<button data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("下移")} aria-label={t("下移")}>↓</button>
|
||||
<button data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
|
||||
<StudioIcon name="arrow" />
|
||||
</button>
|
||||
<button data-action="queue.move" data-target={it.id} data-value="down" onClick={() => onMove(it.id, i + 1)} disabled={i === items.length - 1} title={t("下移")} aria-label={t("下移")}>
|
||||
<StudioIcon name="arrow" data-flip="" />
|
||||
</button>
|
||||
</>}
|
||||
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")}>
|
||||
{t("改")}
|
||||
</button>
|
||||
{/* Waiting is the normal case and needs no button. This is the
|
||||
other one: the turn in front of this line is not worth
|
||||
finishing, and nothing else can end it. */}
|
||||
{running && !queue.paused && (it.state === "queued" || it.state === "steer_accepted") && (
|
||||
<button
|
||||
className="qnow"
|
||||
data-action="queue.send-now"
|
||||
data-target={it.id}
|
||||
onClick={() => onSendNow(it)}
|
||||
title={t("停止当前回复,把这条作为新一轮立即发出")}
|
||||
>
|
||||
{t("立即发送")}
|
||||
</button>
|
||||
)}
|
||||
{it.state === "blocked" && (
|
||||
<button data-action="queue.retry" data-target={it.id} onClick={() => onRetry(it.id)} title={t("重试")}>
|
||||
{t("重试")}
|
||||
|
||||
@@ -3,19 +3,27 @@ import { t } from "../i18n";
|
||||
import type { ModelEntry, RoleAssignments } from "../port/port";
|
||||
import { useDismiss } from "./dismiss";
|
||||
|
||||
// Five jobs, one default. Rendering them as five equal dropdowns would only
|
||||
// move the confusion from the model list to a role list, so the main model is
|
||||
// the anchor and a role draws a branch off it only once it stops following.
|
||||
// Jobs, one default. Rendering them as equal dropdowns would only move the
|
||||
// confusion from the model list to a role list, so the main model is the anchor
|
||||
// and a role draws a branch off it only once it stops following.
|
||||
//
|
||||
// Decision is the one job that cannot follow: it asks a question set, which a
|
||||
// chat model has no answer for. Its slot offers the decision sources and says
|
||||
// so when there are none, rather than pretending the main model will do.
|
||||
|
||||
type RoleKey = keyof RoleAssignments;
|
||||
type Answers = "chat" | "decision";
|
||||
|
||||
const ROLES: [RoleKey, string, string][] = [
|
||||
["planner", "计划", "仅生成计划,不写入"],
|
||||
["subagent", "子代理", "派发的子任务"],
|
||||
["vision", "看图", "处理主模型无法识别的图片"],
|
||||
["guardian", "复核", "独立复核本轮"],
|
||||
const ROLES: [RoleKey, string, string, Answers][] = [
|
||||
["planner", "计划", "仅生成计划,不写入", "chat"],
|
||||
["subagent", "子代理", "派发的子任务", "chat"],
|
||||
["vision", "看图", "处理主模型无法识别的图片", "chat"],
|
||||
["guardian", "复核", "独立复核本轮", "chat"],
|
||||
["decision", "决策", "system_one 询问的后端", "decision"],
|
||||
];
|
||||
|
||||
const answersOf = (m: ModelEntry): Answers => (m.answers === "decision" ? "decision" : "chat");
|
||||
|
||||
interface Props {
|
||||
models: ModelEntry[];
|
||||
roles: RoleAssignments | null;
|
||||
@@ -36,7 +44,8 @@ export function Roles({ models, roles, main, busy, onSet }: Props) {
|
||||
|
||||
if (!roles) return <div className="empty">{t("无法读取角色分工。")}</div>;
|
||||
|
||||
const following = ROLES.filter(([k]) => !roles[k]).length;
|
||||
const chat = ROLES.filter(([, , , a]) => a === "chat");
|
||||
const following = chat.filter(([k]) => !roles[k]).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -45,19 +54,20 @@ export function Roles({ models, roles, main, busy, onSet }: Props) {
|
||||
<span className="cap">{t("对话 · 主模型")}</span>
|
||||
<span className="nm">{anchor?.model ?? main ?? "—"}</span>
|
||||
<span className="meta">
|
||||
{[anchor?.provider, following === ROLES.length ? t("所有角色均跟随主模型") : t("{n} 个角色跟随主模型", { n: following })]
|
||||
{[anchor?.provider, following === chat.length ? t("所有角色均跟随主模型") : t("{n} 个角色跟随主模型", { n: following })]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="fan">
|
||||
{ROLES.map(([key, name, tag]) => (
|
||||
{ROLES.map(([key, name, tag, answers]) => (
|
||||
<Slot
|
||||
key={key}
|
||||
name={t(name)}
|
||||
tag={t(tag)}
|
||||
set={roles[key]}
|
||||
models={models}
|
||||
follows={answers === "chat"}
|
||||
models={models.filter((m) => answersOf(m) === answers)}
|
||||
busy={busy}
|
||||
open={open === key}
|
||||
onOpen={() => setOpen(open === key ? null : key)}
|
||||
@@ -79,9 +89,9 @@ export function Roles({ models, roles, main, busy, onSet }: Props) {
|
||||
}
|
||||
|
||||
function Slot({
|
||||
name, tag, set, models, busy, open, onOpen, onPick,
|
||||
name, tag, set, follows, models, busy, open, onOpen, onPick,
|
||||
}: {
|
||||
name: string; tag: string; set: string; models: ModelEntry[]; busy: string;
|
||||
name: string; tag: string; set: string; follows: boolean; models: ModelEntry[]; busy: string;
|
||||
open: boolean; onOpen: () => void; onPick: (ref: string) => void;
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
@@ -99,7 +109,9 @@ function Slot({
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="rolecopy"><span className="role">{name}</span><span className="tag">{tag}</span></span>
|
||||
<span className="val" key={set || "follow"}>{set ? (chosen?.model ?? set) : t("跟随主模型")}</span>
|
||||
<span className="val" key={set || "follow"}>
|
||||
{set ? (chosen?.model ?? set) : follows ? t("跟随主模型") : models.length ? t("未指定") : t("尚无可用来源")}
|
||||
</span>
|
||||
<i className="slotchev" aria-hidden="true">⌄</i>
|
||||
</button>
|
||||
{/* Kept conditional: .mgrp rounds its corners with overflow:hidden, released
|
||||
@@ -108,9 +120,12 @@ function Slot({
|
||||
{open && (
|
||||
<div className="rpick" role="listbox" aria-label={t("{name}用哪个模型", { name })}>
|
||||
<button role="option" data-action="roles.model" aria-selected={!set} data-cur={!set ? "" : undefined} onClick={() => onPick("")}>
|
||||
{t("跟随主模型")}
|
||||
{follows ? t("跟随主模型") : t("不使用")}
|
||||
</button>
|
||||
<div className="sep" />
|
||||
{!follows && models.length === 0 && (
|
||||
<p className="rpick-empty">{t("在「模型来源」添加一个决策协议的来源后,这里就能选。")}</p>
|
||||
)}
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m.ref}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { Compaction } from "./Compaction";
|
||||
import { Sandbox } from "./Sandbox";
|
||||
import { Account } from "./Account";
|
||||
import { Providers } from "./Providers";
|
||||
import { DecisionModelsPanel } from "./DecisionModels";
|
||||
import { Models, activeKind, groupVendors } from "./Models";
|
||||
import { KIND_LABEL } from "./vendors";
|
||||
import { planProtocolSwitch } from "./protocolswitch";
|
||||
@@ -521,9 +520,6 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
activeKindFor={(a) => kindFor(a.key)}
|
||||
onProtocol={(a, kind) => switchProtocol(a.key, kind)} />
|
||||
</Group>
|
||||
<Group id="decision-models" title={t("决策模型")} hint={t("为 system_one 工具配置辅助决策后端,不会替换当前对话的主模型。")}>
|
||||
<DecisionModelsPanel port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group id="model" title={t("主模型")} now={nav.model} hint={t("用于当前对话和大多数任务。切换会保留对话并重建运行时;任务执行期间无法修改。端点没有声明的能力不会显示标签。")}>
|
||||
<Models models={models} current={status?.modelRef} busy={busy} protocol={protocol}
|
||||
onPick={(ref) => run(ref, () => port.setModel(ref))} />
|
||||
@@ -543,18 +539,18 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
||||
) : (
|
||||
<Group id="effort" title={t("推理强度")} hint={t("当前模型未提供可调的推理档位,因此不显示该选项。")} />
|
||||
)}
|
||||
<Group id="context" title={t("上下文维护")}
|
||||
hint={t("默认按模型容量自动整理。自定义中转站若无法提供最大上下文,先按 160k 计算;你可以在这里填写实际容量。")}>
|
||||
<Compaction port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group id="roles" title={t("按任务指定模型")} now={roles ? t("{n} 个已指派", { n: assigned }) : undefined}
|
||||
hint={t("默认全部使用主模型。只有需要为计划、子代理、看图或复核指定不同模型时才修改这里。")}>
|
||||
hint={t("默认全部跟随主模型;只有要为某件事换一个模型时才改这里。决策是例外,它问的是判断题,必须指向一个决策来源。")}>
|
||||
<Roles models={models} roles={roles} main={status?.modelRef} busy={busy}
|
||||
onSet={(role, ref) => run(`role:${role}`, async () => {
|
||||
await port.setRole(role, ref);
|
||||
loadRoles();
|
||||
})} />
|
||||
</Group>
|
||||
<Group id="context" title={t("上下文维护")}
|
||||
hint={t("默认按模型容量自动整理。自定义中转站若无法提供最大上下文,先按 160k 计算;你可以在这里填写实际容量。")}>
|
||||
<Compaction port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AccountState } from "../port/port";
|
||||
import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
|
||||
import type { RemoteHost } from "../port/remote";
|
||||
import type { Adder } from "./addws";
|
||||
import { AccountRow } from "./AccountRow";
|
||||
import { RailSearch } from "./railsearch";
|
||||
import { RemoteHosts } from "./RemoteHosts";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { Palette, type Command } from "./Palette";
|
||||
import { Workspaces } from "./Workspaces";
|
||||
|
||||
interface Props {
|
||||
hub: HubPort;
|
||||
// Collapsed, the column keeps its scroll position and its folds; inert is the
|
||||
// other half of that — a column nobody can see must not be reachable by Tab.
|
||||
focus: boolean;
|
||||
tree: TreeWorkspace[];
|
||||
runtimes: RuntimeView[];
|
||||
runs: Record<string, { run: string; live: boolean }>;
|
||||
active: string;
|
||||
// Where a new session lands, and the row the tree marks as focused. The window
|
||||
// decides it from the focused pane, so it arrives rather than being derived.
|
||||
activeWorkspace: TreeWorkspace | undefined;
|
||||
liveIds: (ids: string[]) => string[];
|
||||
// Pins outlive this column — archiving a session drops one — so the set is the
|
||||
// window's and only the toggle comes down here.
|
||||
pinned: Set<string>;
|
||||
onPin: (path: string) => void;
|
||||
remotes: RemoteHost[] | null;
|
||||
remoteTrees: Record<string, TreeWorkspace[] | null>;
|
||||
reloadRemotes: () => Promise<void>;
|
||||
reloadRemoteTrees: () => Promise<void>;
|
||||
reloadTree: () => Promise<void>;
|
||||
adder: Adder;
|
||||
onOpen: (req: { root?: string; sessionPath?: string }) => Promise<void>;
|
||||
onOpenRemote: (host: string, workspace?: string, sessionPath?: string) => Promise<void>;
|
||||
onFocusPane: (id: string) => void;
|
||||
onClosePanes: (ids: string[]) => Promise<void>;
|
||||
onPause: (runtimeId: string) => void;
|
||||
onArchive: (path: string, archived: boolean, runtimeId?: string) => Promise<void>;
|
||||
onRename: (path: string, title: string) => void;
|
||||
onCollapse: () => void;
|
||||
account: AccountState | null;
|
||||
accountUnread: string;
|
||||
wallet: string;
|
||||
onSettings: (section?: string) => void;
|
||||
onError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
/** The window's left column: what is mounted, what has been said in it, and who
|
||||
* is signed in. It owns only what nothing outside it reads — which scope the
|
||||
* session filter is on, whether the switcher is open, and which workspaces are
|
||||
* folded. Everything else belongs to the window and arrives as a prop. */
|
||||
export function Sidebar({
|
||||
hub,
|
||||
focus,
|
||||
tree,
|
||||
runtimes,
|
||||
runs,
|
||||
active,
|
||||
activeWorkspace,
|
||||
liveIds,
|
||||
pinned,
|
||||
onPin,
|
||||
remotes,
|
||||
remoteTrees,
|
||||
reloadRemotes,
|
||||
reloadRemoteTrees,
|
||||
reloadTree,
|
||||
adder,
|
||||
onOpen,
|
||||
onOpenRemote,
|
||||
onFocusPane,
|
||||
onClosePanes,
|
||||
onPause,
|
||||
onArchive,
|
||||
onRename,
|
||||
onCollapse,
|
||||
account,
|
||||
accountUnread,
|
||||
wallet,
|
||||
onSettings,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [railScope, setRailScope] = useState<"all" | "live" | "pinned" | "archived">("all");
|
||||
const [palette, setPalette] = useState(false);
|
||||
const [folded, setFolded] = useState<Set<string>>(new Set());
|
||||
const newSessionRoot = activeWorkspace?.root;
|
||||
|
||||
// The shortcut the button prints. It was drawn and never bound, so the one
|
||||
// thing a person learns from the label did nothing.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "k" || e.altKey) return;
|
||||
e.preventDefault();
|
||||
setPalette((on) => !on);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// What the palette can do is what this column already offers, named once so
|
||||
// the two cannot drift into two different lists of the same thing.
|
||||
const commands = useMemo<Command[]>(
|
||||
() => [
|
||||
{ id: "new", label: t("新建会话"), icon: "plus", keywords: "new session chat 新建",
|
||||
run: () => void onOpen({ root: newSessionRoot }).catch(onError) },
|
||||
{ id: "storage", label: t("文件"), icon: "file", keywords: "files storage 文件 存储",
|
||||
run: () => onSettings("storage") },
|
||||
{ id: "ext", label: t("工具与集成"), icon: "plug", keywords: "tools mcp skills 工具 集成",
|
||||
run: () => onSettings("ext") },
|
||||
{ id: "remote", label: t("运行环境"), icon: "server", keywords: "ssh local remote 运行环境",
|
||||
run: () => onSettings("remote") },
|
||||
{ id: "model", label: t("模型来源"), icon: "layers", keywords: "model provider 模型 来源",
|
||||
run: () => onSettings("model") },
|
||||
{ id: "usage", label: t("钱包与用量"), icon: "wallet", keywords: "usage cost token 用量 费用",
|
||||
run: () => onSettings("usage") },
|
||||
{ id: "settings", label: t("设置"), icon: "settings", keywords: "settings preferences 设置 偏好",
|
||||
run: () => onSettings() },
|
||||
],
|
||||
[newSessionRoot, onOpen, onSettings, onError],
|
||||
);
|
||||
const sessionCount = tree.reduce((n, ws) => n + ws.sessions.filter((session) => !session.archived).length, 0);
|
||||
const archivedCount = tree.reduce((n, ws) => n + ws.sessions.filter((session) => session.archived).length, 0);
|
||||
const liveCount = liveIds(runtimes.map((rt) => rt.id)).length;
|
||||
|
||||
const onFold = (root: string, shut: boolean) =>
|
||||
setFolded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (shut) next.add(root);
|
||||
else next.delete(root);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Seeded once, never per reload: after that the folds are the reader's.
|
||||
const seeded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seeded.current || !tree.length) return;
|
||||
seeded.current = true;
|
||||
setFolded(new Set(tree.map((ws) => ws.root).filter((root) => root !== newSessionRoot)));
|
||||
}, [tree, newSessionRoot]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Outside the column's inert wrapper: a collapsed rail must still be
|
||||
reachable by the shortcut that opens this. */}
|
||||
<Palette
|
||||
open={palette}
|
||||
onClose={() => setPalette(false)}
|
||||
commands={commands}
|
||||
tree={tree}
|
||||
onOpenSession={(path) => void onOpen({ sessionPath: path }).catch(onError)}
|
||||
/>
|
||||
{/* 收起而不是卸载:卸掉就丢了侧栏的滚动位置、Inspector 的展开、当前选
|
||||
中的面板。inert 是「看不见就够不着」那一半 —— 只做视觉隐藏的话,
|
||||
屏幕上没有的栏还能被 Tab 走进去。 */}
|
||||
<div className="rail" inert={focus}>
|
||||
<div className="railscroll">
|
||||
<div className="studio-rail-head">
|
||||
<div className="studio-brand" aria-label="Reasonix Studio">
|
||||
<span className="studio-brand-mark" aria-hidden="true"><StudioIcon name="brand" /></span>
|
||||
<span className="studio-brand-word" aria-hidden="true">
|
||||
<b>reasoni<span className="studio-brand-accent">x</span></b>
|
||||
<small>studio</small>
|
||||
</span>
|
||||
<button className="studio-collapse" data-action="chrome.rail" onClick={() => onCollapse()} aria-label={t("收起工作区栏")} title={t("收起侧栏")}><StudioIcon name="panel" /></button>
|
||||
</div>
|
||||
<button
|
||||
className="studio-new-task"
|
||||
data-action="session.new"
|
||||
onClick={() => void onOpen({ root: newSessionRoot }).catch(onError)}
|
||||
>
|
||||
<span aria-hidden="true"><StudioIcon name="plus" /></span>{t("新建会话")}<kbd>Alt N</kbd>
|
||||
</button>
|
||||
<button className="studio-search" data-action="workspace.search" onClick={() => setPalette(true)}>
|
||||
<span aria-hidden="true"><StudioIcon name="search" /></span>{t("搜索与快捷操作")}<kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
<div className="studio-quicknav">
|
||||
<button data-action="settings.section" data-value="storage" onClick={() => onSettings("storage")}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
|
||||
<button data-action="settings.section" data-value="ext" onClick={() => onSettings("ext")}><span aria-hidden="true"><StudioIcon name="plug" /></span><b>{t("工具与集成")}</b><small>MCP · Skills</small></button>
|
||||
<button data-action="settings.section" data-value="remote" onClick={() => onSettings("remote")}><span aria-hidden="true"><StudioIcon name="server" /></span><b>{t("运行环境")}</b><small>Local / SSH</small></button>
|
||||
</div>
|
||||
<div className="studio-section-label studio-session-label"><span>{t("会话")}</span></div>
|
||||
<div className="studio-session-filters" role="group" aria-label={t("会话范围")}>
|
||||
<div className="studio-session-segments">
|
||||
<button data-action="session.filter" data-value="all" aria-pressed={railScope === "all"} onClick={() => setRailScope("all")}><span>{t("全部")}</span><b>{sessionCount}</b></button>
|
||||
<button data-action="session.filter" data-value="live" aria-pressed={railScope === "live"} onClick={() => setRailScope("live")}><span>{t("进行中")}</span><b>{liveCount}</b></button>
|
||||
<button data-action="session.filter" data-value="pinned" aria-pressed={railScope === "pinned"} onClick={() => setRailScope("pinned")}><span>{t("置顶")}</span><b>{pinned.size}</b></button>
|
||||
<button data-action="session.filter" data-value="archived" aria-pressed={railScope === "archived"} onClick={() => setRailScope("archived")}><span>{t("归档")}</span><b>{archivedCount}</b></button>
|
||||
</div>
|
||||
<button className="studio-filter-search" data-action="workspace.search" onClick={() => document.querySelector<HTMLInputElement>(".wsfind input")?.focus()} aria-label={t("按名称筛选会话")}><StudioIcon name="search" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<RailSearch>
|
||||
<Workspaces
|
||||
hub={hub}
|
||||
tree={tree}
|
||||
runtimes={runtimes}
|
||||
active={active}
|
||||
folded={folded}
|
||||
onFold={onFold}
|
||||
reload={reloadTree}
|
||||
onOpen={onOpen}
|
||||
onFocus={onFocusPane}
|
||||
onClose={onClosePanes}
|
||||
liveIds={liveIds}
|
||||
runs={runs}
|
||||
scope={railScope}
|
||||
pinned={pinned}
|
||||
onPin={onPin}
|
||||
onPause={(runtimeId) => onPause(runtimeId)}
|
||||
onArchive={onArchive}
|
||||
onRename={onRename}
|
||||
onError={onError}
|
||||
adder={adder}
|
||||
>
|
||||
{remotes ? (
|
||||
<RemoteHosts
|
||||
hub={hub}
|
||||
hosts={remotes}
|
||||
runtimes={runtimes}
|
||||
active={active}
|
||||
onOpen={onOpenRemote}
|
||||
onFocus={onFocusPane}
|
||||
reload={reloadRemotes}
|
||||
trees={remoteTrees}
|
||||
reloadTrees={reloadRemoteTrees}
|
||||
onError={onError}
|
||||
/>
|
||||
) : null}
|
||||
</Workspaces>
|
||||
</RailSearch>
|
||||
</div>
|
||||
<div className="railfoot">
|
||||
<button className="studio-wallet" data-action="settings.section" data-value="usage" onClick={() => onSettings("usage")}><span aria-hidden="true"><StudioIcon name="wallet" /></span><b>{t("钱包与用量")}</b>{wallet && <small>{wallet}</small>}</button>
|
||||
<div className="studio-user-foot">
|
||||
<AccountRow account={account} unread={accountUnread} onOpen={() => onSettings("account")} />
|
||||
<span className="studio-workspace-kind">{t(account?.signedIn ? "个人工作空间" : "本地工作空间")}</span>
|
||||
<button className="studio-settings" data-action="chrome.settings" onClick={() => onSettings()} aria-label={t("设置")}><StudioIcon name="settings" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const PATHS = {
|
||||
pause: "M8 5v14M16 5v14",
|
||||
warning: "M12 3 2 21h20L12 3zM12 9v5M12 17h.01",
|
||||
copy: "M8 8h13v13H8V8zM16 8V3H3v13h5",
|
||||
quote: "M7 7H3v6h6V9m0-2v8a2 2 0 0 1-2 2H5m16-10h-4v6h6V9m0-2v8a2 2 0 0 1-2 2h-2",
|
||||
external: "M14 3h7v7M21 3 11 13M10 3H3v18h18v-7",
|
||||
plug: "M8 3v5m8-5v5M5 8h14v4a7 7 0 0 1-14 0V8M12 19v3",
|
||||
download: "M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type RefObject, type SetStateAction } from "react";
|
||||
import { decimals } from "../i18n/format";
|
||||
import { t } from "../i18n";
|
||||
import type { Item, Waiting } from "../state/session";
|
||||
@@ -9,7 +9,7 @@ import { ToolCard } from "./cards/ToolCard";
|
||||
import { GuardianCard } from "./cards/GuardianCard";
|
||||
import { ApprovalCard, type PlanAction } from "./cards/ApprovalCard";
|
||||
import { AskCard } from "./cards/AskCard";
|
||||
import { SayCard } from "./cards/SayCard";
|
||||
import { SayCard, type ReplyActions } from "./cards/SayCard";
|
||||
import { CompactionCard } from "./cards/CompactionCard";
|
||||
import { ReceiptCard } from "./cards/ReceiptCard";
|
||||
import { ReadsCard } from "./cards/ReadsCard";
|
||||
@@ -46,12 +46,16 @@ interface Props {
|
||||
onForget: (itemId: string, name: string) => void;
|
||||
// Takes a still-queued line back. Only rows the kernel has given an
|
||||
// item id can offer it, and only until the turn reads them.
|
||||
onCancelQueued: (rowId: string, itemId: string) => void;
|
||||
onExtInvoke: (name: string) => void;
|
||||
// Views published against a tool call, keyed by anchor. A card looks itself
|
||||
// up here rather than being handed one, so an arriving takeover repaints the
|
||||
// one card it names and nothing else.
|
||||
takeovers?: Record<string, ExtensionSurface>;
|
||||
// What a finished reply can be acted on with. One object rather than five
|
||||
// props: they are one capability set and travel together.
|
||||
reply?: ReplyActions;
|
||||
// Rewriting a message: the turn it takes back, and what to send instead.
|
||||
onResend?: (turn: number, text: string) => Promise<void>;
|
||||
onExtSubmit: (pluginId: string, surfaceId: string, values: Record<string, unknown>) => void;
|
||||
// The checkpoint each user card can return to, keyed by item id. Absent for a
|
||||
// card whose turn could not be matched — see state/checkpoints.
|
||||
@@ -104,7 +108,7 @@ function useBlocks(items: Item[], cut: number, revision: number): Item[][] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function Transcript({ items, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onCancelQueued, onExtInvoke, onExtSubmit, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
|
||||
export function Transcript({ items, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
|
||||
// A block the selection touches must not leave the DOM. Unmounting the node a
|
||||
// selection is anchored to makes the browser remap that selection onto
|
||||
// whatever is still mounted — which reads as "I selected up there and the
|
||||
@@ -443,7 +447,7 @@ export function Transcript({ items, entering, onEntered, revision, waiting, scro
|
||||
}, [entering, onEntered]);
|
||||
const owed = useMemo(() => new Set(entering), [entering]);
|
||||
|
||||
const rowProps = { owed, onApprove, onFullAccess, onPlan, onAnswer, onForget, onCancelQueued, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
|
||||
const rowProps = { owed, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend };
|
||||
|
||||
// What you said, and where it sits. Derived from the same blocks the
|
||||
// transcript renders, so a mark always knows which block holds it — that is
|
||||
@@ -535,6 +539,9 @@ const Block = memo(function Block({
|
||||
// Tool traffic is evidence for the answer, not a stack of equally important
|
||||
// messages; transcriptRows folds it and keeps each sentence above its own.
|
||||
const rows = transcriptRows(items);
|
||||
// Which runs of work are open, held here so re-hosting one under its sentence
|
||||
// does not close it.
|
||||
const [opened, setOpened] = useState<Record<string, boolean>>({});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -548,10 +555,10 @@ const Block = memo(function Block({
|
||||
it={row.item}
|
||||
{...rowProps}
|
||||
cp={checkpoints.get(row.item.id)}
|
||||
afterAnswer={row.activity ? <ActivityGroup items={row.activity} checkpoints={checkpoints} {...rowProps} /> : undefined}
|
||||
afterAnswer={row.activity ? <ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} /> : undefined}
|
||||
/>
|
||||
) : (
|
||||
<ActivityGroup key={`activity:${row.activity[0].id}`} items={row.activity} checkpoints={checkpoints} {...rowProps} />
|
||||
<ActivityGroup key={`activity:${row.activity[0].id}`} items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -564,7 +571,6 @@ interface RowHandlers {
|
||||
onPlan: Props["onPlan"];
|
||||
onAnswer: Props["onAnswer"];
|
||||
onForget: Props["onForget"];
|
||||
onCancelQueued: Props["onCancelQueued"];
|
||||
onExtInvoke: Props["onExtInvoke"];
|
||||
takeovers: Record<string, ExtensionSurface>;
|
||||
onExtSubmit: Props["onExtSubmit"];
|
||||
@@ -572,16 +578,39 @@ interface RowHandlers {
|
||||
onCommitRewind: Props["onCommitRewind"];
|
||||
onUndoRewind: Props["onUndoRewind"];
|
||||
onPrepareFileRevert: Props["onPrepareFileRevert"];
|
||||
reply: Props["reply"];
|
||||
onResend: Props["onResend"];
|
||||
onCommitFileRevert: Props["onCommitFileRevert"];
|
||||
}
|
||||
|
||||
const ActivityGroup = memo(function ActivityGroup({
|
||||
items,
|
||||
checkpoints,
|
||||
opened,
|
||||
onOpened,
|
||||
...rowProps
|
||||
}: { items: Item[]; checkpoints: Map<string, Checkpoint> } & RowHandlers) {
|
||||
}: {
|
||||
items: Item[];
|
||||
checkpoints: Map<string, Checkpoint>;
|
||||
opened: Record<string, boolean>;
|
||||
onOpened: Dispatch<SetStateAction<Record<string, boolean>>>;
|
||||
} & RowHandlers) {
|
||||
const running = items.some((item) => item.t === "tool" && item.running);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Whether this group is open is held above it, because the group itself does
|
||||
// not survive the turn: a run of work starts as a row of its own and is
|
||||
// re-hosted under the sentence it belongs to once that sentence arrives,
|
||||
// which unmounts it. State kept inside would be lost at exactly that moment.
|
||||
const gid = items[0]?.id ?? "";
|
||||
const open = opened[gid] ?? false;
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => onOpened((all) => (all[gid] === next ? all : { ...all, [gid]: next })),
|
||||
[gid, onOpened],
|
||||
);
|
||||
// Open while something is running: a collapsed group during execution is a
|
||||
// window with nothing moving in it, which reads as stuck rather than busy.
|
||||
useEffect(() => {
|
||||
if (running) setOpen(true);
|
||||
}, [running, setOpen]);
|
||||
const calls = items.reduce((count, item) => count + (item.t === "reads" ? item.tools.length : 1), 0);
|
||||
const failures = items.reduce((count, item) => {
|
||||
if (item.t === "tool") return count + (toolFailed(item.tool) ? 1 : 0);
|
||||
@@ -615,9 +644,10 @@ const Row = memo(function Row({
|
||||
onFullAccess,
|
||||
onPlan,
|
||||
onForget,
|
||||
onCancelQueued,
|
||||
onAnswer,
|
||||
onExtInvoke,
|
||||
reply,
|
||||
onResend,
|
||||
takeovers,
|
||||
onExtSubmit,
|
||||
cp,
|
||||
@@ -645,7 +675,7 @@ const Row = memo(function Row({
|
||||
<UserCard
|
||||
item={it}
|
||||
cp={cp}
|
||||
onCancelQueued={onCancelQueued}
|
||||
onResend={onResend}
|
||||
onPrepareRewind={onPrepareRewind}
|
||||
onCommitRewind={onCommitRewind}
|
||||
onUndoRewind={onUndoRewind}
|
||||
@@ -656,7 +686,7 @@ const Row = memo(function Row({
|
||||
// model. Gating the card on text meant all of it stayed invisible and
|
||||
// then landed at once. An empty card is still not a message, so a turn
|
||||
// that produced neither draws nothing.
|
||||
return it.text.trim() || it.reasoning?.trim() ? <SayCard item={it} afterAnswer={afterAnswer} /> : null;
|
||||
return it.text.trim() || it.reasoning?.trim() ? <SayCard item={it} afterAnswer={afterAnswer} reply={reply} /> : null;
|
||||
case "tool":
|
||||
// The ask tool also raises ask_request, which carries the id /answer
|
||||
// needs. Drawing the tool call too put two copies of the same question on
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("WorkbenchPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
const port = new MockPort();
|
||||
const save = vi.spyOn(port, "saveWorkspaceFile");
|
||||
render(<WorkbenchPanel port={port} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={vi.fn()} onExternal={vi.fn()} />);
|
||||
render(<WorkbenchPanel port={port} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "README.md" }));
|
||||
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
|
||||
@@ -24,7 +24,7 @@ describe("WorkbenchPanel", () => {
|
||||
});
|
||||
|
||||
it("puts manual and agent browsers on the same tab strip", () => {
|
||||
render(<WorkbenchPanel port={new MockPort()} tabs={[{ id: "b1", target: "t1", url: "https://example.com", title: "Agent page", active: true }]} manual shown={false} scheme="dark" changes={[]} onCloseManual={vi.fn()} onExternal={vi.fn()} />);
|
||||
render(<WorkbenchPanel port={new MockPort()} tabs={[{ id: "b1", target: "t1", url: "https://example.com", title: "Agent page", active: true }]} manual shown={false} scheme="dark" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
expect(screen.getByRole("tab", { name: "浏览器" })).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Agent page" })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { host } from "../port/host";
|
||||
import type {
|
||||
AgentPort,
|
||||
BrowserTab,
|
||||
@@ -13,7 +14,7 @@ import { LazyMarkdown } from "./LazyMarkdown";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
type Surface =
|
||||
| { kind: "manual" }
|
||||
| { kind: "manual"; id: string }
|
||||
| { kind: "browser"; tab: BrowserTab }
|
||||
| { kind: "file"; path: string };
|
||||
type TreeRow = {
|
||||
@@ -45,18 +46,30 @@ const LANGUAGE: Record<string, string> = {
|
||||
};
|
||||
|
||||
function keyOf(s: Surface) {
|
||||
return s.kind === "manual"
|
||||
? "manual"
|
||||
: `${s.kind}:${s.kind === "browser" ? s.tab.target : s.path}`;
|
||||
return `${s.kind}:${s.kind === "manual" ? s.id : s.kind === "browser" ? s.tab.target : s.path}`;
|
||||
}
|
||||
function labelOf(s: Surface) {
|
||||
function labelOf(s: Surface, hosts: Record<string, string>) {
|
||||
return s.kind === "manual"
|
||||
? t("浏览器")
|
||||
? hosts[s.id] || t("浏览器")
|
||||
: s.kind === "browser"
|
||||
? s.tab.title || s.tab.url || t("空白页")
|
||||
: s.path.split("/").at(-1) || s.path;
|
||||
}
|
||||
|
||||
/** Folders first at every level, with the tree still reading as a tree. Sorting
|
||||
* flat paths as text interleaves `bin/` with `boot.test.exe`, because text is
|
||||
* the only thing it compares. Segment by segment, whichever row is a directory
|
||||
* where the two paths part wins there, and its children stay under it. */
|
||||
function before(a: TreeRow, b: TreeRow): number {
|
||||
const x = a.path.split("/"), y = b.path.split("/");
|
||||
for (let i = 0; i < Math.min(x.length, y.length); i++) {
|
||||
if (x[i] === y[i]) continue;
|
||||
const dirs = [i < x.length - 1 || a.kind === "folder", i < y.length - 1 || b.kind === "folder"];
|
||||
return dirs[0] === dirs[1] ? x[i].localeCompare(y[i]) : dirs[0] ? -1 : 1;
|
||||
}
|
||||
return x.length - y.length;
|
||||
}
|
||||
|
||||
function treeRows(
|
||||
files: string[],
|
||||
directories: string[],
|
||||
@@ -96,9 +109,7 @@ function treeRows(
|
||||
depth: path.split("/").length - 1,
|
||||
})),
|
||||
);
|
||||
return rows.sort(
|
||||
(a, b) => a.path.localeCompare(b.path) || (a.kind === "folder" ? -1 : 1),
|
||||
);
|
||||
return rows.sort(before);
|
||||
}
|
||||
|
||||
export function WorkbenchPanel({
|
||||
@@ -109,6 +120,7 @@ export function WorkbenchPanel({
|
||||
scheme,
|
||||
changes,
|
||||
onCloseManual,
|
||||
onSurfaces,
|
||||
onExternal,
|
||||
}: {
|
||||
port: AgentPort;
|
||||
@@ -118,15 +130,24 @@ export function WorkbenchPanel({
|
||||
scheme: "light" | "dark";
|
||||
changes: WorkspaceChange[];
|
||||
onCloseManual: () => void;
|
||||
// How many surfaces the strip holds, for the pane's own tab to count.
|
||||
onSurfaces: (n: number) => void;
|
||||
onExternal: (url: string) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<string[]>([]),
|
||||
[directories, setDirectories] = useState<string[]>([]),
|
||||
[openFiles, setOpenFiles] = useState<string[]>([]);
|
||||
// Docked, the body is too narrow for two columns, so the explorer is hidden
|
||||
// — and the workbench is always docked now, which left the files with no way
|
||||
// in at all. The toggle is that way in, at any width.
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [query, setQuery] = useState(""),
|
||||
[selected, setSelected] = useState("");
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set()),
|
||||
[collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
const [browsers, setBrowsers] = useState<string[]>([]),
|
||||
[hosts, setHosts] = useState<Record<string, string>>({});
|
||||
const minted = useRef(0);
|
||||
const [mode, setMode] = useState<"file" | "diff">("file"),
|
||||
[editing, setEditing] = useState(false);
|
||||
const [file, setFile] = useState<WorkspaceFile | null>(null),
|
||||
@@ -148,17 +169,28 @@ export function WorkbenchPanel({
|
||||
(e) => setFailed(reason(e)),
|
||||
);
|
||||
}, [port, shown, changeKey, query]);
|
||||
// The button in the chrome says "show me the browser", not "show me this one
|
||||
// browser": it seeds the first tab and the strip's + opens the rest.
|
||||
// Closing the panel is not closing the pages: they come back with it, and
|
||||
// only a tab's own × takes one away.
|
||||
useEffect(() => {
|
||||
if (manual) setSelected("manual");
|
||||
if (!manual) return;
|
||||
setBrowsers((open) => (open.length ? open : ["b0"]));
|
||||
setSelected((at) => (at.startsWith("manual:") ? at : "manual:b0"));
|
||||
}, [manual]);
|
||||
const noteHost = useCallback(
|
||||
(id: string, host: string) =>
|
||||
setHosts((v) => (v[id] === host ? v : { ...v, [id]: host })),
|
||||
[],
|
||||
);
|
||||
const surfaces = useMemo<Surface[]>(
|
||||
() =>
|
||||
[
|
||||
...(manual ? [{ kind: "manual" } as const] : []),
|
||||
...browsers.map((id) => ({ kind: "manual", id }) as const),
|
||||
...tabs.map((tab) => ({ kind: "browser", tab }) as const),
|
||||
...openFiles.map((path) => ({ kind: "file", path }) as const),
|
||||
].filter((s) => !dismissed.has(keyOf(s))),
|
||||
[manual, tabs, openFiles, dismissed],
|
||||
[browsers, tabs, openFiles, dismissed],
|
||||
);
|
||||
const active =
|
||||
surfaces.find((s) => keyOf(s) === selected) ??
|
||||
@@ -168,6 +200,7 @@ export function WorkbenchPanel({
|
||||
() => treeRows(files, directories, query, collapsed),
|
||||
[files, directories, query, collapsed],
|
||||
);
|
||||
useEffect(() => onSurfaces(surfaces.length), [onSurfaces, surfaces.length]);
|
||||
useEffect(() => {
|
||||
if (!active || active.kind !== "file") return;
|
||||
let live = true;
|
||||
@@ -223,10 +256,31 @@ export function WorkbenchPanel({
|
||||
setFailed(reason(e));
|
||||
}
|
||||
};
|
||||
// One browser: a tab opened here is a tab the agent can read and drive, and
|
||||
// it is drawn as a view rather than framed — which is the only way a site
|
||||
// that refuses to be framed opens at all. A shell with no views has no such
|
||||
// page to give, so there the iframe surface is still what there is.
|
||||
const addBrowser = async () => {
|
||||
if (!host().drawsBrowserViews()) {
|
||||
const id = `b${(minted.current += 1)}`;
|
||||
setBrowsers((v) => [...v, id]);
|
||||
setSelected(`manual:${id}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tab = await port.browserOpen("about:blank", true);
|
||||
setSelected(`browser:${tab.target}`);
|
||||
} catch (e) {
|
||||
setFailed(reason(e));
|
||||
}
|
||||
};
|
||||
const close = (surface: Surface) => {
|
||||
const key = keyOf(surface);
|
||||
if (surface.kind === "manual") onCloseManual();
|
||||
else if (surface.kind === "file")
|
||||
if (surface.kind === "manual") {
|
||||
const rest = browsers.filter((id) => id !== surface.id);
|
||||
setBrowsers(rest);
|
||||
if (!rest.length) onCloseManual();
|
||||
} else if (surface.kind === "file")
|
||||
setOpenFiles((v) => v.filter((p) => p !== surface.path));
|
||||
else setDismissed((v) => new Set(v).add(key));
|
||||
if (selected === key) setSelected("");
|
||||
@@ -251,31 +305,36 @@ export function WorkbenchPanel({
|
||||
: "plaintext";
|
||||
return (
|
||||
<section className="workbench" aria-label={t("工作台")}>
|
||||
<header className="workbench-tabs" role="tablist">
|
||||
<header className="workbench-tabs">
|
||||
<div className="workbench-tablist" role="tablist">
|
||||
{surfaces.map((surface) => (
|
||||
<div
|
||||
className="workbench-tab"
|
||||
key={keyOf(surface)}
|
||||
role="tab"
|
||||
aria-selected={surface === active}
|
||||
data-live={surface.kind === "browser" && surface.tab.active ? "" : undefined}
|
||||
>
|
||||
<button
|
||||
className="workbench-tab-pick"
|
||||
data-action="workbench.tab"
|
||||
data-target={keyOf(surface)}
|
||||
title={
|
||||
surface.kind === "browser" ? surface.tab.url : labelOf(surface)
|
||||
surface.kind === "browser"
|
||||
? `${surface.tab.active ? `${t("模型正在操作这个页面")}
|
||||
` : ""}${surface.tab.url}`
|
||||
: labelOf(surface, hosts)
|
||||
}
|
||||
onClick={() => setSelected(keyOf(surface))}
|
||||
>
|
||||
<StudioIcon name={surface.kind === "file" ? "file" : "globe"} />
|
||||
<span>{labelOf(surface)}</span>
|
||||
<span>{labelOf(surface, hosts)}</span>
|
||||
</button>
|
||||
<button
|
||||
className="workbench-tab-close"
|
||||
data-action="workbench.close"
|
||||
data-target={keyOf(surface)}
|
||||
aria-label={t("关闭 {name}", { name: labelOf(surface) })}
|
||||
aria-label={t("关闭 {name}", { name: labelOf(surface, hosts) })}
|
||||
title={t("关闭")}
|
||||
onClick={() => close(surface)}
|
||||
>
|
||||
@@ -283,8 +342,28 @@ export function WorkbenchPanel({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="workbench-files"
|
||||
data-action="workbench.files"
|
||||
aria-pressed={showFiles}
|
||||
aria-label={t("文件")}
|
||||
title={t("文件")}
|
||||
onClick={() => setShowFiles((on) => !on)}
|
||||
>
|
||||
<StudioIcon name="folder" />
|
||||
</button>
|
||||
<button
|
||||
className="workbench-new"
|
||||
data-action="workbench.new-browser"
|
||||
aria-label={t("新建浏览器标签")}
|
||||
title={t("新建浏览器标签")}
|
||||
onClick={() => void addBrowser()}
|
||||
>
|
||||
<StudioIcon name="plus" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="workbench-body">
|
||||
<div className="workbench-body" data-files={showFiles ? "" : undefined}>
|
||||
<main className="workbench-canvas">
|
||||
{!active && (
|
||||
<div className="workbench-empty">
|
||||
@@ -292,13 +371,18 @@ export function WorkbenchPanel({
|
||||
<span>{t("从右侧选择文件,或打开浏览器")}</span>
|
||||
</div>
|
||||
)}
|
||||
{active?.kind === "manual" && (
|
||||
{/* Every open browser stays mounted: a tab switch must not throw away
|
||||
where that page had got to. */}
|
||||
{browsers.map((id) => (
|
||||
<ManualBrowserPanel
|
||||
onClose={onCloseManual}
|
||||
key={id}
|
||||
id={id}
|
||||
hidden={!(active?.kind === "manual" && active.id === id)}
|
||||
onAddress={noteHost}
|
||||
onExternal={onExternal}
|
||||
scheme={scheme}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{active?.kind === "browser" && (
|
||||
<AgentBrowserPanel
|
||||
tabs={[active.tab]}
|
||||
@@ -398,6 +482,38 @@ export function WorkbenchPanel({
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
{/* What differs, without opening anything. The tree marks a changed
|
||||
file only once its folder is expanded, so a change three levels
|
||||
down was invisible until someone went looking for it. */}
|
||||
{changes.length > 0 && (
|
||||
<div className="workbench-changes">
|
||||
<div className="workbench-explorer-head">
|
||||
<span>{t("改动")}</span>
|
||||
<small>{changes.length}</small>
|
||||
</div>
|
||||
{changes.map((item) => (
|
||||
<button
|
||||
className="workbench-change-row"
|
||||
data-action="workbench.diff"
|
||||
data-target={item.path}
|
||||
key={`c:${item.path}`}
|
||||
title={item.path}
|
||||
aria-current={active?.kind === "file" && active.path === item.path && mode === "diff" ? "page" : undefined}
|
||||
onClick={() => {
|
||||
openFile(item.path);
|
||||
setMode("diff");
|
||||
}}
|
||||
>
|
||||
<em data-status={item.status}>{item.status || "M"}</em>
|
||||
<span>{item.path.slice(item.path.lastIndexOf("/") + 1)}</span>
|
||||
{/* Absent counts print nothing: a binary file did not change
|
||||
by zero lines, git simply did not count it. */}
|
||||
{item.insertions !== undefined && <i data-io="up">+{item.insertions}</i>}
|
||||
{item.deletions !== undefined && <i data-io="down">-{item.deletions}</i>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="workbench-filelist">
|
||||
{rows.map((row) =>
|
||||
row.kind === "folder" ? (
|
||||
|
||||
@@ -35,3 +35,21 @@ describe("the anchor a run-graph node lands on", () => {
|
||||
expect(html).not.toContain("data-call");
|
||||
});
|
||||
});
|
||||
|
||||
// The group above a call already says work is happening. A call that opens
|
||||
// itself moves the transcript under the reader at every step of a long turn,
|
||||
// and leaves it in a state nobody chose. Failure is the exception: that is the
|
||||
// one thing a reader is being asked to look at. Fixing this once by changing
|
||||
// the initial state left the effect still forcing it open, so both are pinned.
|
||||
describe("whether a call opens itself", () => {
|
||||
const opened = (html: string) => /<details[^>]*\bopen\b/.test(html);
|
||||
|
||||
it("stays shut while it runs", () => {
|
||||
expect(opened(renderToStaticMarkup(<ToolCard tool={call("t1", "todo_write")} running />))).toBe(false);
|
||||
});
|
||||
|
||||
it("opens when the call failed, which is what a reader is being shown", () => {
|
||||
const failed: Tool = { id: "t2", name: "bash", readOnly: false, err: "boom" };
|
||||
expect(opened(renderToStaticMarkup(<ToolCard tool={failed} running={false} />))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { StudioIcon } from "../StudioIcon";
|
||||
import { t } from "../../i18n";
|
||||
import { count, decimals } from "../../i18n/format";
|
||||
import { Sym } from "../Sym";
|
||||
@@ -20,8 +21,33 @@ function thoughtLabel(item: Extract<Item, { t: "say" }>) {
|
||||
: t("思考 {chars}", { chars });
|
||||
}
|
||||
|
||||
export function SayCard({ item, afterAnswer }: { item: Extract<Item, { t: "say" }>; afterAnswer?: ReactNode }) {
|
||||
// A ref is "provider/model"; the model is what identifies it to a reader, and
|
||||
// the provider stays in the title for when two of them carry the same name.
|
||||
const modelName = (ref: string) => ref.slice(ref.lastIndexOf("/") + 1);
|
||||
|
||||
/** What a finished reply can be acted on with. Absent members are capabilities
|
||||
* this transcript does not have — a rebuilt one cannot re-run a turn — and the
|
||||
* bar draws only what it can actually do. */
|
||||
export interface ReplyActions {
|
||||
onQuote: (text: string) => void;
|
||||
onRegenerate?: () => void;
|
||||
model?: string;
|
||||
onConfigureModel?: () => void;
|
||||
onRunDetail?: () => void;
|
||||
}
|
||||
|
||||
function download(text: string) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/markdown;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "reasonix-reply.md";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function SayCard({ item, afterAnswer, reply }: { item: Extract<Item, { t: "say" }>; afterAnswer?: ReactNode; reply?: ReplyActions }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<"" | "retry" | "more">("");
|
||||
// Thinking is the longest-running stream of the turn — 10s of it before the
|
||||
// first answer token, measured — so it gets the same paced reveal the answer
|
||||
// does rather than tracking the wire's bursts.
|
||||
@@ -35,6 +61,9 @@ export function SayCard({ item, afterAnswer }: { item: Extract<Item, { t: "say"
|
||||
<div className="c">
|
||||
<div className="hl">
|
||||
<span className="nm">reasonix</span>
|
||||
{/* Which model wrote this one. A turn can carry two, and without it
|
||||
both read as whatever the composer happens to name now. */}
|
||||
{item.model && <span className="src" title={item.model}>{modelName(item.model)}</span>}
|
||||
</div>
|
||||
<div className="out">
|
||||
{item.reasoning && (
|
||||
@@ -59,8 +88,56 @@ export function SayCard({ item, afterAnswer }: { item: Extract<Item, { t: "say"
|
||||
{/* Only once the answer is whole: copying half a stream hands over
|
||||
something that was never said. */}
|
||||
{item.done && item.text.trim() && (
|
||||
<div className="acts">
|
||||
<div className="acts" onMouseLeave={() => setMenu("")}>
|
||||
<CopyButton text={item.text} iconOnly />
|
||||
{reply && (
|
||||
<button type="button" data-action="reply.quote" title={t("引用到输入框")} aria-label={t("引用到输入框")} onClick={() => reply.onQuote(item.text)}>
|
||||
<StudioIcon name="quote" />
|
||||
</button>
|
||||
)}
|
||||
{reply?.onRegenerate && (
|
||||
<span className="acts-menu">
|
||||
<button type="button" data-action="reply.retry" title={t("重新生成")} aria-label={t("重新生成")} aria-expanded={menu === "retry"} onClick={() => setMenu((m) => (m === "retry" ? "" : "retry"))}>
|
||||
<StudioIcon name="refresh" />
|
||||
</button>
|
||||
{menu === "retry" && (
|
||||
<div className="acts-pop" role="menu">
|
||||
<div className="acts-pop-head">{t("重新生成")}<small>{t("当前回复会留在运行历史里")}</small></div>
|
||||
<button type="button" role="menuitem" data-action="reply.retry-now" onClick={() => { setMenu(""); reply.onRegenerate?.(); }}>
|
||||
<StudioIcon name="refresh" /><span>{t("按当前配置重试")}</span>{reply.model && <small>{reply.model}</small>}
|
||||
</button>
|
||||
{reply.onConfigureModel && (
|
||||
<button type="button" role="menuitem" data-action="reply.configure" onClick={() => { setMenu(""); reply.onConfigureModel?.(); }}>
|
||||
<StudioIcon name="sliders" /><span>{t("先调整模型与强度")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{reply && (
|
||||
<span className="acts-menu">
|
||||
<button type="button" data-action="reply.more" title={t("更多")} aria-label={t("更多")} aria-expanded={menu === "more"} onClick={() => setMenu((m) => (m === "more" ? "" : "more"))}>
|
||||
<StudioIcon name="more" />
|
||||
</button>
|
||||
{menu === "more" && (
|
||||
<div className="acts-pop" role="menu">
|
||||
<div className="acts-pop-head">{t("这条回复")}</div>
|
||||
<button type="button" role="menuitem" data-action="reply.download" onClick={() => { setMenu(""); download(item.text); }}>
|
||||
<StudioIcon name="download" /><span>{t("下载回复")}</span><small>Markdown</small>
|
||||
</button>
|
||||
{reply.onRunDetail && (
|
||||
<>
|
||||
<div className="acts-pop-head">{t("本轮执行")}</div>
|
||||
<button type="button" role="menuitem" data-action="reply.run-detail" onClick={() => { setMenu(""); reply.onRunDetail?.(); }}>
|
||||
<StudioIcon name="gauge" /><span>{t("运行分析")}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,10 +107,13 @@ export function ToolCard({
|
||||
// one claim — done, still going, or stuck — so the card says the claim rather
|
||||
// than the object carrying it.
|
||||
const goal = tool.name === "update_goal" ? goalUpdate(tool.args) : null;
|
||||
const [open, setOpen] = useState(running || bad);
|
||||
// Running is not a reason to show the arguments: the group above already says
|
||||
// the work is happening, and a call that opens itself pushes the transcript
|
||||
// around while it reads. A failure is different — that is what is being asked.
|
||||
const [open, setOpen] = useState(bad);
|
||||
useEffect(() => {
|
||||
if (running || bad) setOpen(true);
|
||||
}, [running, bad]);
|
||||
if (bad) setOpen(true);
|
||||
}, [bad]);
|
||||
const changes = changeCounts(tool);
|
||||
const hasBody = Boolean(
|
||||
takeover || tool.diff || goal || tool.name === "todo_write" ||
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { Item } from "../../state/session";
|
||||
import { RewindControl } from "./RewindControl";
|
||||
import { reason } from "../../i18n/kernel";
|
||||
import { t } from "../../i18n";
|
||||
import { StudioIcon } from "../StudioIcon";
|
||||
|
||||
export function UserCard({
|
||||
item,
|
||||
cp,
|
||||
onResend,
|
||||
onCancelQueued,
|
||||
onPrepareRewind,
|
||||
onCommitRewind,
|
||||
onUndoRewind,
|
||||
@@ -17,15 +17,10 @@ export function UserCard({
|
||||
item: Extract<Item, { t: "user" }>;
|
||||
cp?: Checkpoint;
|
||||
onResend?: (turn: number, text: string) => Promise<void>;
|
||||
onCancelQueued?: (rowId: string, itemId: string) => void;
|
||||
onPrepareRewind?: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
|
||||
onCommitRewind?: (planId: string) => Promise<RewindResult>;
|
||||
onUndoRewind?: (transactionId: string) => Promise<void>;
|
||||
}) {
|
||||
// A queued line is the only thing on screen that has not happened yet, which
|
||||
// makes it the only thing that can still be taken back. The id is the
|
||||
// kernel's, and it goes away the moment the turn reads the line.
|
||||
const queued = item.pending ? item.itemId : undefined;
|
||||
// A rewind needs a turn the kernel claimed, and a queued line has not
|
||||
// happened yet — there is nothing behind it to take back.
|
||||
const editable = !!onResend && !!cp && !item.pending;
|
||||
@@ -60,16 +55,9 @@ export function UserCard({
|
||||
</div>
|
||||
<div className="c">
|
||||
<div className="hl user-hl">
|
||||
{item.pending && (
|
||||
<span className="pend">
|
||||
{item.queued === "followup" ? t("排队中 · 本轮结束后发送") : t("排队中 · 下一个工具边界送达")}
|
||||
</span>
|
||||
)}
|
||||
{queued && onCancelQueued && (
|
||||
<button className="pcancel" data-action="queue.cancel" data-target={item.id} onClick={() => onCancelQueued(item.id, queued)}>
|
||||
{t("撤回")}
|
||||
</button>
|
||||
)}
|
||||
{/* It reached the model inside a turn already running, which is why
|
||||
there is no checkpoint on this row to rewind to. */}
|
||||
{item.steer && <span className="steermark">{t("插话")}</span>}
|
||||
{/* The entry point lives on the turn it returns to, so there is no
|
||||
list to read and no turn number to match up by eye. */}
|
||||
{editable && draft === null && (
|
||||
@@ -80,7 +68,7 @@ export function UserCard({
|
||||
title={t("改写这条消息并重新发送")}
|
||||
onClick={() => setDraft(item.text)}
|
||||
>
|
||||
{t("✎ 改写")}
|
||||
<StudioIcon name="edit" />{t("改写")}
|
||||
</button>
|
||||
)}
|
||||
{cp && onPrepareRewind && onCommitRewind && onUndoRewind && (
|
||||
|
||||
@@ -3,23 +3,35 @@ import { current } from "../i18n";
|
||||
import { refresh as refreshWidth } from "./viewport";
|
||||
|
||||
/** 正文起点。汉字同字号下笔画更密,中文高一档。求值留到调用时:模块加载早于
|
||||
* i18n 定语言。这一档和 --doc 一起定行长:两个数只有一起看才有意义。 */
|
||||
export const readDefault = (): number => (current() === "zh" ? 16 : 15);
|
||||
* i18n 定语言。这一档和 --doc 一起定行长:两个数只有一起看才有意义。
|
||||
* 这就是界面实际渲染的字号 —— 各个阅读面从它换算,不再各写各的字面值。 */
|
||||
export const readDefault = (): number => (current() === "zh" ? 13 : 12.5);
|
||||
|
||||
/** 正文曾被各个阅读面写死,档位是围着那时的字号排的。存下来的是"哪一档"而不
|
||||
* 是像素,所以旧值按档位读回,选过"标准"的人还是标准。 */
|
||||
const RETIRED_STEPS: Record<string, number[]> = { zh: [14.5, 16, 18, 20], en: [13.5, 15, 17, 19] };
|
||||
|
||||
export function readSizeOf(stored?: number): number {
|
||||
if (!stored) return readDefault();
|
||||
const retired = RETIRED_STEPS[current() === "zh" ? "zh" : "en"];
|
||||
const rung = retired.indexOf(stored);
|
||||
return rung < 0 ? stored : readSteps()[rung][0];
|
||||
}
|
||||
|
||||
/** 字号档位。中间那档就是 readDefault(),两边一起改。 */
|
||||
export const readSteps = (): [number, string][] =>
|
||||
current() === "zh"
|
||||
? [
|
||||
[14.5, "小"],
|
||||
[16, "标准"],
|
||||
[18, "大"],
|
||||
[20, "更大"],
|
||||
[12, "小"],
|
||||
[13, "标准"],
|
||||
[15, "大"],
|
||||
[17, "更大"],
|
||||
]
|
||||
: [
|
||||
[13.5, "小"],
|
||||
[15, "标准"],
|
||||
[17, "大"],
|
||||
[19, "更大"],
|
||||
[11.5, "小"],
|
||||
[12.5, "标准"],
|
||||
[14.5, "大"],
|
||||
[16.5, "更大"],
|
||||
];
|
||||
|
||||
// The user's own settings, applied after a pack so they win: a pack is a
|
||||
@@ -56,7 +68,7 @@ export function apply(look: Appearance | null, busy = false) {
|
||||
|
||||
// Reading size moves the transcript's prose alone, so the frame around it
|
||||
// stays where the layout put it.
|
||||
style.setProperty("--read", `${look?.readSize || readDefault()}px`);
|
||||
style.setProperty("--read", `${readSizeOf(look?.readSize)}px`);
|
||||
|
||||
// How far that prose runs is the other half of the same question, and the
|
||||
// only one with two honest answers: a line short enough to read without
|
||||
|
||||
@@ -22,7 +22,7 @@ function draw(items: Item[], entering: string[], hidden = false, onPinned = () =
|
||||
waiting={{}} scroll={scroll} hidden={hidden} onPinned={onPinned}
|
||||
jump={0} focus={null}
|
||||
onApprove={noop} onFullAccess={noop} onPlan={noop} onAnswer={noop} onForget={noop}
|
||||
onCancelQueued={() => {}} onExtInvoke={() => {}} onExtSubmit={noop}
|
||||
onExtInvoke={() => {}} onExtSubmit={noop}
|
||||
checkpoints={new Map()} onPrepareRewind={noop} onCommitRewind={noop} onUndoRewind={noop}
|
||||
onPrepareFileRevert={noop} onCommitFileRevert={noop}
|
||||
needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
|
||||
@@ -91,7 +91,7 @@ describe("a card's one entrance", () => {
|
||||
jump={0} focus={null}
|
||||
onApprove={(async () => undefined) as never} onFullAccess={(async () => undefined) as never} onPlan={(async () => undefined) as never}
|
||||
onAnswer={(async () => undefined) as never} onForget={(async () => undefined) as never}
|
||||
onCancelQueued={() => {}} onExtInvoke={() => {}} onExtSubmit={(async () => undefined) as never}
|
||||
onExtInvoke={() => {}} onExtSubmit={(async () => undefined) as never}
|
||||
checkpoints={new Map()} onPrepareRewind={(async () => undefined) as never}
|
||||
onCommitRewind={(async () => undefined) as never} onUndoRewind={(async () => undefined) as never}
|
||||
onPrepareFileRevert={(async () => undefined) as never} onCommitFileRevert={(async () => undefined) as never}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { adopt as adoptLang } from "../i18n";
|
||||
import type { Appearance as Look, ThemePack } from "../port/port";
|
||||
import type { HubPort, RuntimeView } from "../port/hub";
|
||||
import { apply as applyLook } from "./look";
|
||||
import { apply as applyThemePack } from "./theme";
|
||||
import { trailing } from "./trailing";
|
||||
|
||||
export interface Paint {
|
||||
theme: string;
|
||||
setTheme: (v: string) => void;
|
||||
// What "auto" resolved to. Panes take it as a prop, not off the root.
|
||||
scheme: "light" | "dark";
|
||||
contrast: string;
|
||||
setContrast: (v: string) => void;
|
||||
weight: string;
|
||||
setWeight: (v: string) => void;
|
||||
look: Look;
|
||||
onLook: (next: Look) => void;
|
||||
pack: ThemePack | null;
|
||||
reloadThemes: () => void;
|
||||
}
|
||||
|
||||
/** How this window is painted: the scheme it resolved to, the pack behind it,
|
||||
* and the reader's own size and contrast. One concern, and the only one that
|
||||
* writes `documentElement`'s dataset, so nothing else has to know the order
|
||||
* a pack and a look have to be applied in. */
|
||||
export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean, onError: (e: unknown) => void): Paint {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("rx-theme") ?? "auto");
|
||||
const [scheme, setScheme] = useState<"light" | "dark">("dark");
|
||||
// "" means never chosen, which is what lets the system's own contrast setting
|
||||
// decide. Any explicit pick wins over it from then on.
|
||||
const [contrast, setContrast] = useState(() => localStorage.getItem("rx-contrast") ?? "");
|
||||
// 空串是「跟随语言」:中文界面本来就该比西文粗一档,样式表按 :lang 给默认。
|
||||
const [weight, setWeight] = useState(() => localStorage.getItem("rx-weight") ?? "");
|
||||
const [pack, setPack] = useState<ThemePack | null>(null);
|
||||
const [look, setLook] = useState<Look>({});
|
||||
|
||||
// Appearance is the window's, not the focused pane's. A remote kernel keeps
|
||||
// its own copy, and adopting that one repaints this window because the
|
||||
// reader changed tabs — so these three read and write the local pane only.
|
||||
const lookPort = useMemo(() => {
|
||||
const local = runtimes.find((rt) => !rt.host);
|
||||
return local ? hub.portFor(local) : null;
|
||||
}, [hub, runtimes]);
|
||||
|
||||
const reloadThemes = useCallback(() => {
|
||||
lookPort
|
||||
?.themes()
|
||||
.then((list) => setPack(list.find((p) => p.active) ?? null))
|
||||
.catch(() => setPack(null));
|
||||
}, [lookPort]);
|
||||
useEffect(reloadThemes, [reloadThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
lookPort
|
||||
?.appearance()
|
||||
.then((next) => {
|
||||
// The kernel's copy is the authority across machines; adopt reloads if
|
||||
// this window booted in the wrong language from a stale local cache.
|
||||
if (adoptLang(next.language)) return;
|
||||
setLook(next);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [lookPort]);
|
||||
|
||||
// The control moves now and the config catches up: a size or a colour that
|
||||
// waits on a round trip reads as a dead click. The kernel's answer is still
|
||||
// what is kept, since it clamps what the slider sent.
|
||||
const saveLook = useMemo(
|
||||
() => (lookPort ? trailing((next: Look) => lookPort.saveAppearance(next), setLook, onError) : null),
|
||||
[lookPort, onError],
|
||||
);
|
||||
const onLook = useCallback(
|
||||
(next: Look) => {
|
||||
setLook(next);
|
||||
saveLook?.(next);
|
||||
},
|
||||
[saveLook],
|
||||
);
|
||||
|
||||
// A pack carries a light and a dark set, so it is repainted with the scheme
|
||||
// rather than once at load: switching the OS to dark has to move both. The
|
||||
// running flag rides along because a pack's picture recedes while a turn is
|
||||
// in flight — the transition is in CSS, this only moves the target.
|
||||
useEffect(() => {
|
||||
const mq = matchMedia("(prefers-color-scheme: dark)");
|
||||
const paint = () => {
|
||||
const now = theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
|
||||
document.documentElement.dataset.theme = now;
|
||||
setScheme(now as "light" | "dark");
|
||||
// The active pack is the same state shown by the appearance picker.
|
||||
// Applying `null` here made activation persist in the kernel while the
|
||||
// shell deliberately kept drawing the default palette, so a successful
|
||||
// click looked broken until forever. Keep the authored Studio layout,
|
||||
// but let the selected pack supply its documented colour/background
|
||||
// tokens immediately.
|
||||
applyThemePack(pack, now as "light" | "dark", running, contrast);
|
||||
// After the pack, never before: size and type are the reader's, and a
|
||||
// palette someone else authored does not get to overrule them.
|
||||
applyLook({ ...look, wallpaper: undefined }, running);
|
||||
};
|
||||
paint();
|
||||
mq.addEventListener("change", paint);
|
||||
localStorage.setItem("rx-theme", theme);
|
||||
return () => mq.removeEventListener("change", paint);
|
||||
}, [theme, pack, running, look, contrast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contrast) document.documentElement.dataset.contrast = contrast;
|
||||
else delete document.documentElement.dataset.contrast;
|
||||
localStorage.setItem("rx-contrast", contrast);
|
||||
if (weight) document.documentElement.dataset.weight = weight;
|
||||
else delete document.documentElement.dataset.weight;
|
||||
localStorage.setItem("rx-weight", weight);
|
||||
}, [contrast, weight]);
|
||||
|
||||
return { theme, setTheme, scheme, contrast, setContrast, weight, setWeight, look, onLook, pack, reloadThemes };
|
||||
}
|
||||
@@ -45,6 +45,9 @@ function open(visible: boolean) {
|
||||
needsProject: false,
|
||||
onOpenProject: () => {},
|
||||
onKeepHere: () => {},
|
||||
theme: "dark",
|
||||
dockW: 560,
|
||||
onDockW: () => {},
|
||||
};
|
||||
const view = render(<Pane {...props} />);
|
||||
return {
|
||||
|
||||
@@ -184,7 +184,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "session", anchor: "plan-mode", title: "计划模式", scope: "session", apply: "immediate", keywords: ["只读", "先规划"] },
|
||||
{ section: "session", anchor: "session-dir", title: "会话写入位置", scope: "workspace", apply: "none", keywords: ["工作目录", "路径"] },
|
||||
|
||||
{ section: "model", anchor: "roles", title: "按任务指定模型", scope: "machine", apply: "runtime-rebuild", keywords: ["角色分工", "子代理", "规划", "执行", "审查"] },
|
||||
{ section: "model", anchor: "roles", title: "按任务指定模型", scope: "machine", apply: "runtime-rebuild", keywords: ["角色分工", "子代理", "规划", "执行", "审查", "决策", "system one", "typesafe", "laya"] },
|
||||
{ section: "model", anchor: "model", title: "主模型", scope: "machine", apply: "runtime-rebuild", keywords: ["模型", "切换", "端点"] },
|
||||
// Written onto the provider entry, not the model row: SetProviderEffort keys
|
||||
// by provider name, so every model reached through that source shares it.
|
||||
@@ -198,7 +198,6 @@ export const SETTINGS: SettingEntry[] = [
|
||||
// is what the row promises, because the weaker one would be a promise this
|
||||
// block cannot keep.
|
||||
{ section: "model", anchor: "providers", title: "模型来源", scope: "machine", apply: "runtime-rebuild", keywords: ["连接", "提供商", "api key", "密钥", "协议", "地址"] },
|
||||
{ section: "model", anchor: "decision-models", title: "决策模型", scope: "machine", apply: "runtime-rebuild", keywords: ["system one", "typesafe", "laya", "辅助决策"] },
|
||||
|
||||
// Takes effect on this session at once and is also persisted as the default
|
||||
// every later session starts from. Machine is the wider of the two answers
|
||||
|
||||
@@ -36,9 +36,11 @@ function draw(onRead: (id: string) => Promise<string>) {
|
||||
render(
|
||||
<Queue
|
||||
queue={snapshot()}
|
||||
running
|
||||
onRead={onRead}
|
||||
onEdit={onEdit}
|
||||
onMove={() => {}}
|
||||
onSendNow={() => {}}
|
||||
onCancel={() => {}}
|
||||
onRetry={() => {}}
|
||||
onRefresh={() => {}}
|
||||
|
||||
@@ -20,13 +20,15 @@ const snapshot = (over: Partial<QueueSnapshot> = {}): QueueSnapshot => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
const draw = (q: QueueSnapshot) =>
|
||||
const paint = (q: QueueSnapshot, running: boolean) =>
|
||||
renderToStaticMarkup(
|
||||
<Queue
|
||||
queue={q}
|
||||
running={running}
|
||||
onRead={async () => ""}
|
||||
onEdit={() => {}}
|
||||
onMove={() => {}}
|
||||
onSendNow={() => {}}
|
||||
onCancel={() => {}}
|
||||
onRetry={() => {}}
|
||||
onRefresh={() => {}}
|
||||
@@ -34,16 +36,27 @@ const draw = (q: QueueSnapshot) =>
|
||||
/>,
|
||||
);
|
||||
|
||||
const draw = (q: QueueSnapshot) => paint(q, true);
|
||||
const drawIdle = (q: QueueSnapshot) => paint(q, false);
|
||||
|
||||
// Typing at a working turn does reach the model — the report was that nobody
|
||||
// could tell. Every assertion here is on what the strip says back, because a
|
||||
// steer whose only evidence is the model's next sentence reads as ignored.
|
||||
describe("what the pending strip says back", () => {
|
||||
// A background job's follow-on is queued the same way a typed line is, so
|
||||
// without saying whose it is the strip reports the runtime as the user.
|
||||
it("names a host continuation as the background job it came from", () => {
|
||||
const html = draw(snapshot({ items: [item({ origin: "host", state: "queued", intent: "followup" })] }));
|
||||
expect(html).toContain("后台任务续接");
|
||||
expect(html).not.toContain("插话排队");
|
||||
// "To send" is the person's outbox. A background job's continuation is not
|
||||
// something they typed, and listing it there asked them to take back a line
|
||||
// they never wrote.
|
||||
it("keeps a host continuation out of the outbox", () => {
|
||||
expect(draw(snapshot({ items: [item({ origin: "host", state: "queued", intent: "followup" })] }))).toBe("");
|
||||
});
|
||||
|
||||
// Unless it is stuck: a decision only they can make is the one case it has
|
||||
// to reach them here.
|
||||
it("shows a host continuation that needs a decision", () => {
|
||||
const html = draw(snapshot({ items: [item({ origin: "host", state: "blocked", intent: "followup" })] }));
|
||||
expect(html).toContain("受阻");
|
||||
});
|
||||
|
||||
it("names an accepted steer as taken, not as waiting", () => {
|
||||
@@ -54,7 +67,7 @@ describe("what the pending strip says back", () => {
|
||||
|
||||
it("keeps a queued follow-up apart from a steer the kernel took", () => {
|
||||
const html = draw(snapshot({ items: [item({ intent: "followup", state: "queued" })] }));
|
||||
expect(html).toContain("当前回复结束后发送");
|
||||
expect(html).toContain("尚未送达,可以改,也可以取回");
|
||||
expect(html).not.toContain("插话已收");
|
||||
});
|
||||
|
||||
@@ -63,16 +76,30 @@ describe("what the pending strip says back", () => {
|
||||
});
|
||||
|
||||
// Past the tool boundary the text is the model's, and the kernel refuses the
|
||||
// withdrawal. Offering it anyway is a button that answers with an error.
|
||||
it("drops the take-back once the turn has read the line", () => {
|
||||
expect(draw(snapshot({ items: [item({ state: "steer_consumed" })] }))).not.toContain("取回");
|
||||
// withdrawal. The entry does not stay on as a read-only copy either: the
|
||||
// transcript has the line by then, and two of it is what read as stuck.
|
||||
it("hands a line over to the transcript once the turn has read it", () => {
|
||||
expect(draw(snapshot({ items: [item({ state: "steer_consumed" })] }))).toBe("");
|
||||
});
|
||||
|
||||
// A follow-up the kernel dispatched is the running turn, and the running turn
|
||||
// is the transcript's to draw.
|
||||
it("hands a dispatched follow-up over the same way", () => {
|
||||
expect(draw(snapshot({ items: [item({ intent: "followup", state: "running" })] }))).toBe("");
|
||||
});
|
||||
|
||||
// Waiting needs no button; this is the other case, and it is only meaningful
|
||||
// against a turn that is actually holding the session.
|
||||
it("offers sending now only while a turn is in the way", () => {
|
||||
expect(draw(snapshot())).toContain("立即发送");
|
||||
expect(drawIdle(snapshot())).not.toContain("立即发送");
|
||||
});
|
||||
|
||||
it("keeps technical capacity out of the ordinary one-line queue", () => {
|
||||
const html = draw(snapshot());
|
||||
expect(html).not.toContain("1/64");
|
||||
expect(html).not.toContain("3.1k/64M");
|
||||
expect(html).toContain("当前回复结束后发送");
|
||||
expect(html).toContain("尚未送达,可以改,也可以取回");
|
||||
});
|
||||
|
||||
// Capacity becomes useful when it needs attention rather than occupying the
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { AgentPort, Checkpoint } from "../port/port";
|
||||
import type { Item } from "../state/session";
|
||||
import { pairCheckpoints } from "../state/checkpoints";
|
||||
import { t } from "../i18n";
|
||||
import type { ReplyActions } from "./cards/SayCard";
|
||||
|
||||
interface Inputs {
|
||||
port: AgentPort;
|
||||
items: Item[];
|
||||
checkpoints: Checkpoint[];
|
||||
running: boolean;
|
||||
model?: string;
|
||||
submit: (text: string) => Promise<boolean>;
|
||||
onSettings: (section?: string) => void;
|
||||
onRunDetail: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
/** What a finished reply can be acted on with, and the draft signal a quote
|
||||
* travels to the composer on. The transcript owns neither: the pane holds the
|
||||
* session these read from, and the composer is where a quote has to land. */
|
||||
export function useReplyActions({ port, items, checkpoints, running, model, submit, onSettings, onRunDetail, onError }: Inputs) {
|
||||
const [quote, setQuote] = useState<{ text: string; n: number }>({ text: "", n: 0 });
|
||||
|
||||
// Re-running a turn is a conversation rewind and then the same words again:
|
||||
// the transcript goes back, the files do not, and the reply that was there
|
||||
// stays in the history the rewind wrote. Scope is conversation for exactly
|
||||
// that reason — reverting the work too would be a far larger promise than
|
||||
// the word "regenerate" makes.
|
||||
const regenerate = useCallback(
|
||||
async (turn: number, text: string) => {
|
||||
try {
|
||||
const plan = await port.prepareRewind(turn, "conversation");
|
||||
if (!plan.canConversation) throw new Error(plan.disabledReason || t("这一轮无法重新生成"));
|
||||
await port.commitRewind(plan.planId);
|
||||
await submit(text);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
},
|
||||
[port, submit, onError],
|
||||
);
|
||||
|
||||
// The last thing the person said, and the turn the kernel gave it. A reply is
|
||||
// re-run by sending that again, so a transcript with no checkpoint behind it
|
||||
// offers no regenerate rather than a button that answers with an error. The
|
||||
// pairing is the transcript's own: a rebuilt row carries msgIndex and no
|
||||
// authored turn, so matching on the latter found nothing in a reopened
|
||||
// session — which is most of them.
|
||||
const lastAsk = useMemo(() => {
|
||||
const paired = pairCheckpoints(items, checkpoints);
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (item.t !== "user" || item.pending) continue;
|
||||
const cp = paired.get(item.id);
|
||||
return cp ? { turn: cp.turn, text: item.text } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}, [items, checkpoints]);
|
||||
|
||||
const reply = useMemo<ReplyActions>(
|
||||
() => ({
|
||||
onQuote: (text: string) => setQuote((q) => ({ text, n: q.n + 1 })),
|
||||
onRegenerate: lastAsk && !running ? () => void regenerate(lastAsk.turn, lastAsk.text) : undefined,
|
||||
model,
|
||||
onConfigureModel: () => onSettings("model"),
|
||||
onRunDetail,
|
||||
}),
|
||||
[lastAsk, running, regenerate, model, onSettings, onRunDetail],
|
||||
);
|
||||
|
||||
// Rewriting a message is the same act with different words: the turn goes
|
||||
// back and what the person now means goes out. The card asks for it by the
|
||||
// turn its own checkpoint named, so it cannot aim at a turn that moved.
|
||||
const onResend = useCallback((turn: number, text: string) => regenerate(turn, text), [regenerate]);
|
||||
|
||||
return { quote, reply, onResend };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback } from "react";
|
||||
import type { AgentPort, RewindScope } from "../port/port";
|
||||
|
||||
/** Taking a turn back, and taking one file back. A rewind rewrites the
|
||||
* transcript and the files under it, so the whole session is re-read the way a
|
||||
* session switch is. Reverting one file touches disk but not the transcript,
|
||||
* so it reloads nothing. */
|
||||
export function useRewindActions(port: AgentPort, reloadSession: () => void) {
|
||||
const onPrepareRewind = useCallback((turn: number, scope: RewindScope) => port.prepareRewind(turn, scope), [port]);
|
||||
const onCommitRewind = useCallback(
|
||||
async (planId: string) => {
|
||||
const result = await port.commitRewind(planId);
|
||||
reloadSession();
|
||||
return result;
|
||||
},
|
||||
[port, reloadSession],
|
||||
);
|
||||
const onUndoRewind = useCallback((transactionId: string) => port.undoRewind(transactionId).then(reloadSession), [port, reloadSession]);
|
||||
const onPrepareFileRevert = useCallback((path: string) => port.prepareFileRevert(path), [port]);
|
||||
const onCommitFileRevert = useCallback((planId: string, resolution?: string) => port.commitFileRevert(planId, resolution), [port]);
|
||||
return { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import { MockHub } from "../port/mock_hub";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// The second folder the fixture knows. The switcher names it; a new session has
|
||||
// to land in it.
|
||||
// The second folder the fixture knows. Working in it is what focuses it, and a
|
||||
// new session from the head has to land there rather than in the first row.
|
||||
const SECOND = "~/projects/my-website";
|
||||
|
||||
// Where a new session lands is the whole claim: the fake kernel records which
|
||||
// folder every open asked for, so a button reading a different answer than the
|
||||
// switcher above it shows up as the wrong folder rather than as styling.
|
||||
// folder every open asked for, so a head button reading a different answer than
|
||||
// the workspace on screen shows up as the wrong folder rather than as styling.
|
||||
describe("a new session opens in the workspace on screen", () => {
|
||||
it("does not fall back to the first folder in the tree", async () => {
|
||||
const hub = new MockHub();
|
||||
@@ -36,8 +36,11 @@ describe("a new session opens in the workspace on screen", () => {
|
||||
|
||||
render(<App hub={hub} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /当前聚焦/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: /my-website/ }));
|
||||
// The tree is the only place a workspace is named now. Folders other than
|
||||
// the focused one open shut, so this is the whole way in: unfold, then work
|
||||
// in it.
|
||||
await userEvent.click(await screen.findByRole("treeitem", { name: /my-website/ }));
|
||||
await userEvent.click(await screen.findByRole("treeitem", { name: /站点改版/ }));
|
||||
await waitFor(() => expect(asked).toEqual([SECOND]));
|
||||
|
||||
asked.length = 0;
|
||||
|
||||
@@ -75,6 +75,17 @@ export function apply(pack: ThemePack | null, scheme: "light" | "dark", busy = f
|
||||
for (const v of vars) root.style.removeProperty(v);
|
||||
}
|
||||
root.style.removeProperty("--accent-wash");
|
||||
// Contrast is the reader's, so it moves the built-in inks too — not only a
|
||||
// pack that happens to declare them. Read after the clear above, or each pass
|
||||
// would offset the one before it.
|
||||
const base = getComputedStyle(root);
|
||||
for (const name of Object.keys(STEPS)) {
|
||||
const authored = pack?.tokens[scheme]?.[name];
|
||||
const from = authored || base.getPropertyValue(SURFACE[name][0]).trim();
|
||||
if (!authored && from) {
|
||||
for (const v of SURFACE[name]) root.style.setProperty(v, ink(from, name, scheme, contrast));
|
||||
}
|
||||
}
|
||||
if (!pack) return;
|
||||
|
||||
const tokens = pack.tokens[scheme];
|
||||
|
||||
@@ -31,7 +31,6 @@ function draw(items: Item[]) {
|
||||
onPlan={noop}
|
||||
onAnswer={noop}
|
||||
onForget={noop}
|
||||
onCancelQueued={() => {}}
|
||||
onExtInvoke={() => {}}
|
||||
onExtSubmit={noop}
|
||||
checkpoints={new Map()}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Item } from "../state/session_types";
|
||||
import { transcriptRows } from "./turnrows";
|
||||
|
||||
const me = (id: string, text: string, pending?: boolean): Item => ({ t: "user", id, text, pending });
|
||||
const said = (id: string, text: string): Item => ({ t: "say", id, text, done: true });
|
||||
const ran = (id: string): Item => ({ t: "tool", id, tool: { id, name: "bash" } as never, running: false, children: [] });
|
||||
|
||||
const shape = (items: Item[]) =>
|
||||
transcriptRows(items).map((r) => ("item" in r ? `${r.item.t}${r.activity ? `+${r.activity.length}` : ""}` : `work:${r.activity.length}`));
|
||||
|
||||
describe("what a turn looks like once it is laid out", () => {
|
||||
it("files each run of work under the sentence it followed", () => {
|
||||
expect(shape([me("u1", "go"), said("s1", "on it"), ran("t1"), ran("t2"), said("s2", "done")]))
|
||||
.toEqual(["user", "say+2", "say"]);
|
||||
});
|
||||
|
||||
// A line typed into a running turn used to sit here from the moment it was
|
||||
// typed. It split the turn at that point, so every tool call that followed
|
||||
// was filed under an instruction the model had not read — the transcript
|
||||
// reporting work as done about a line that had not been sent.
|
||||
it("leaves a queued line out rather than splitting the turn it was typed into", () => {
|
||||
const withQueued = [me("u1", "go"), said("s1", "on it"), me("u2", "also this", true), ran("t1"), said("s2", "done")];
|
||||
expect(shape(withQueued)).toEqual(shape([me("u1", "go"), said("s1", "on it"), ran("t1"), said("s2", "done")]));
|
||||
});
|
||||
|
||||
it("draws the line as soon as the kernel has taken it", () => {
|
||||
expect(shape([me("u1", "go"), said("s1", "on it"), me("u2", "also this")])).toEqual(["user", "say", "user"]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@ type Row = { item: Item; activity?: Item[] } | { activity: Item[] };
|
||||
* message and everything done about it, consecutive tool steps fold into one
|
||||
* disclosure, and each run of work sits under the sentence it followed — a turn
|
||||
* that answers several times keeps each answer with its own work. */
|
||||
export function transcriptRows(items: Item[]): Row[] {
|
||||
export function transcriptRows(all: Item[]): Row[] {
|
||||
// A queued line has not happened yet, so it is the composer's to show and not
|
||||
// this list's. Leaving it here also split the running turn at the moment of
|
||||
// typing and handed its work to a line the model had not read.
|
||||
const items = all.filter((i) => !(i.t === "user" && i.pending));
|
||||
const rows: Row[] = [];
|
||||
for (let at = 0; at < items.length;) {
|
||||
let end = at + 1;
|
||||
|
||||
@@ -8,6 +8,7 @@ export const KIND_LABEL: Record<string, string> = {
|
||||
openai: "OpenAI 兼容",
|
||||
anthropic: "Anthropic 兼容",
|
||||
responses: "Responses",
|
||||
typesafe: "决策 · System One",
|
||||
extension: "扩展",
|
||||
};
|
||||
|
||||
|
||||
+19
-8
@@ -611,9 +611,18 @@ func midTurnSteerMessage(text string, host bool) string {
|
||||
// marker is cut from the returned text, so replay recognizes steers
|
||||
// regardless of the session's language and profile settings.
|
||||
func SteerText(content string) (string, bool) {
|
||||
text, _, ok := SteerKind(content)
|
||||
return text, ok
|
||||
}
|
||||
|
||||
// SteerKind is SteerText plus whose steer it was. Only the prefix tells the
|
||||
// two apart, so a caller that shows the line to the person asks here instead
|
||||
// of reading the wording: rendering the host's mid-turn notice as something
|
||||
// they said puts words in their mouth in the one place they read them back.
|
||||
func SteerKind(content string) (string, bool, bool) {
|
||||
s := content
|
||||
for {
|
||||
for _, prefix := range []string{MidTurnSteerPrefix, HostNoticePrefix} {
|
||||
for i, prefix := range []string{MidTurnSteerPrefix, HostNoticePrefix} {
|
||||
after, found := strings.CutPrefix(s, prefix)
|
||||
if !found {
|
||||
continue
|
||||
@@ -623,23 +632,25 @@ func SteerText(content string) (string, bool) {
|
||||
if trimmed, cut := strings.CutSuffix(after, "\n\n"+DeliveryRuntimeMarker); cut {
|
||||
after = trimmed
|
||||
}
|
||||
return after, true
|
||||
return after, i == 1, true
|
||||
}
|
||||
next, ok := trimLeadingSteerWrapper(s)
|
||||
if !ok {
|
||||
return "", false
|
||||
return "", false, false
|
||||
}
|
||||
s = next
|
||||
}
|
||||
}
|
||||
|
||||
// trimLeadingSteerWrapper removes one leading transient preference block that
|
||||
// withTurnPreferences may have placed ahead of the steer prefix. It reports
|
||||
// false when content does not start with such a block.
|
||||
// trimLeadingSteerWrapper removes one leading transient block the host may
|
||||
// have placed ahead of the steer prefix. It walks TransientUserBlockTags for
|
||||
// the same reason hasLeadingInjectedBlock does: a tag the injector knows and
|
||||
// this walk does not stops it early, and the steer behind that block reads
|
||||
// back as the user having typed the host's own instructions at them.
|
||||
func trimLeadingSteerWrapper(content string) (string, bool) {
|
||||
s := strings.TrimLeft(content, " \t\r\n")
|
||||
for _, tag := range []string{"response-language", "reasoning-language"} {
|
||||
if !strings.HasPrefix(s, "<"+tag+">") {
|
||||
for _, tag := range TransientUserBlockTags {
|
||||
if !hasOpenTag(s, tag) {
|
||||
continue
|
||||
}
|
||||
if rest, ok := trimLeadingTransientBlock(s, tag); ok {
|
||||
|
||||
@@ -125,7 +125,7 @@ func (a *Agent) announceOwnTurn(ctx context.Context, pending provider.Message) {
|
||||
a.svc.sink.Emit(started)
|
||||
return
|
||||
}
|
||||
a.svc.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
a.svc.sink.Emit(event.Event{Kind: event.TurnStarted, ModelRef: a.modelRef})
|
||||
}
|
||||
|
||||
// verifyAuthoredLanding checks the published identity against the message that
|
||||
@@ -158,5 +158,5 @@ func (a *Agent) turnStartedEvent(pending provider.Message) (event.Event, bool) {
|
||||
return event.Event{}, false
|
||||
}
|
||||
index := len(msgs)
|
||||
return event.Event{Kind: event.TurnStarted, AuthoredTurn: &class.AuthoredTurn, MsgIndex: &index}, true
|
||||
return event.Event{Kind: event.TurnStarted, AuthoredTurn: &class.AuthoredTurn, MsgIndex: &index, ModelRef: a.modelRef}, true
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
|
||||
// here would make planner work and executor work two turns to every sink
|
||||
// that resets on a start.
|
||||
if _, hosted := HostTurnBoundaryFrom(ctx); !hosted {
|
||||
c.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
c.sink.Emit(event.Event{Kind: event.TurnStarted, ModelRef: c.executor.modelRef})
|
||||
}
|
||||
// A turn starts owing nothing to the last one's plan; deliverPlan installs
|
||||
// this turn's plan only once the executor is actually about to run it.
|
||||
@@ -340,7 +340,7 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
|
||||
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.executor.svc.prov.Name() + " · executing", Detail: routeDetail, Source: event.UsageSourceExecutor})
|
||||
return c.executor.Run(ctx, input)
|
||||
}
|
||||
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
plannerCtx := tool.WithoutGoalTurnRecorder(ctx)
|
||||
if decision.MaxResearchRounds > 0 {
|
||||
plannerCtx = withRunStepLimit(plannerCtx, decision.MaxResearchRounds, "planner research rounds")
|
||||
@@ -380,7 +380,7 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
|
||||
// A planner failure must not take down the turn: the executor is
|
||||
// healthy and owns the full tool set, so degrade to single-model for
|
||||
// this turn.
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: plannerFallbackNotice, Detail: "planner failed; running the executor without a plan: " + err.Error(), Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: plannerFallbackNotice, Detail: "planner failed; running the executor without a plan: " + err.Error(), Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.executor.svc.prov.Name() + " · executing", Source: event.UsageSourceExecutor})
|
||||
return c.executor.Run(ctx, input)
|
||||
}
|
||||
@@ -396,7 +396,7 @@ func (c *Coordinator) deliverPlan(ctx context.Context, input string, outcome pla
|
||||
c.persistExecutorNoOp(ctx, input, plan)
|
||||
// The relayed conclusion is planner text; keep its source so sinks
|
||||
// attribute it like every other planner emission.
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: DisplayAssistantText(plan), Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: DisplayAssistantText(plan), Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
return nil
|
||||
}
|
||||
runExecutorWithPlan := func(ctx context.Context, planText string) error {
|
||||
@@ -409,7 +409,7 @@ func (c *Coordinator) deliverPlan(ctx context.Context, input string, outcome pla
|
||||
runWithPlanApproval := func() error {
|
||||
if c.plannerPlanApprover == nil {
|
||||
c.persistExecutorNoOp(ctx, input, plan+"\n\n"+plannerPlanAwaitingApprovalNote)
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: plannerPlanAwaitingApprovalNotice, Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: plannerPlanAwaitingApprovalNotice, Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
return nil
|
||||
}
|
||||
executed := false
|
||||
@@ -422,13 +422,13 @@ func (c *Coordinator) deliverPlan(ctx context.Context, input string, outcome pla
|
||||
// path does — a denied turn must survive session save/reload, and
|
||||
// the note tells the next executor turn that nothing ran.
|
||||
c.persistExecutorNoOp(ctx, input, plan+"\n\n"+plannerPlanNotApprovedNote)
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: plannerPlanNotApprovedNotice, Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: plannerPlanNotApprovedNotice, Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if decision.Route == PlannerRoutePlanOnly {
|
||||
c.persistExecutorNoOp(ctx, input, plan+"\n\n"+plannerPlanOnlyNote)
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: plannerPlanOnlyNotice, Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: plannerPlanOnlyNotice, Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
return nil
|
||||
}
|
||||
if decision.Route == PlannerRoutePlanForApproval {
|
||||
@@ -551,7 +551,7 @@ func (c *Coordinator) planFromStream(ctx context.Context, input string) (string,
|
||||
switch chunk.Type {
|
||||
case provider.ChunkText:
|
||||
text.WriteString(chunk.Text)
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text, Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text, Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
case provider.ChunkUsage:
|
||||
usage = chunk.Usage
|
||||
case provider.ChunkError:
|
||||
@@ -592,7 +592,7 @@ func (c *Coordinator) planWithTools(ctx context.Context, input string) (plannerO
|
||||
}
|
||||
if plan, ok := submission.Plan(); ok {
|
||||
text := plancontract.Render(plan)
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: text, Source: event.UsageSourcePlanner})
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: text, Source: event.UsageSourcePlanner, ModelRef: c.plannerModelRef})
|
||||
return plannerOutcome{text: text, plan: plan, exit: plannerExitPlan}, nil
|
||||
}
|
||||
// The plan is this turn's final answer: the last non-empty assistant
|
||||
|
||||
@@ -327,6 +327,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *turnRuntime) error {
|
||||
ToolCalls: calls,
|
||||
ResponsesItems: responsesItems,
|
||||
WorkDurationMs: state.workDurationMs(),
|
||||
ModelRef: a.modelRef,
|
||||
})
|
||||
|
||||
if len(calls) == 0 {
|
||||
|
||||
@@ -132,3 +132,18 @@ func TestHostNoticeDoesNotClaimTheUserSpoke(t *testing.T) {
|
||||
t.Fatalf("host prefix = %q, want it to disclaim the user", HostNoticePrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Every block the host prepends to a user turn is declared once, and a steer
|
||||
// behind any of them is still a steer. The recogniser walks that declaration
|
||||
// rather than a copy: a tag it does not know stops the walk, and the steer
|
||||
// reads back as the person having typed the host's instructions at themselves.
|
||||
func TestSteerTextSeesThroughEveryDeclaredTransientBlock(t *testing.T) {
|
||||
for _, tag := range TransientUserBlockTags {
|
||||
wrapped := "<" + tag + ">\nwhat the host had to say\n</" + tag + ">\n\n" +
|
||||
MidTurnSteerPrefix + "\n" + "用户自己说的话"
|
||||
got, ok := SteerText(wrapped)
|
||||
if !ok || got != "用户自己说的话" {
|
||||
t.Errorf("<%s>: SteerText = %q, %v; want the user's own words", tag, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1145,9 +1145,10 @@ func (t *restrictedCapabilityProxy) Execute(ctx context.Context, args json.RawMe
|
||||
}
|
||||
_ = json.Unmarshal(args, &p)
|
||||
action := strings.ToLower(strings.TrimSpace(p.Action))
|
||||
if action == "list" {
|
||||
switch action {
|
||||
case "list":
|
||||
return filterCapabilityListResult(out, t.servers), nil
|
||||
} else if action == "search" {
|
||||
case "search":
|
||||
return filterCapabilitySearchResult(out, t.allowed), nil
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -2665,14 +2665,22 @@ func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddBuiltinsBindsConfiguredSystemOne(t *testing.T) {
|
||||
// The decision backend is an ordinary source the decision role names, so the
|
||||
// tool binds from the same place every other model comes from.
|
||||
func TestAddBuiltinsBindsTheDecisionRolesBackend(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
cfg := &config.Config{Tools: config.ToolsConfig{SystemOne: config.SystemOneConfig{}}}
|
||||
t.Setenv("TYPESAFE_API_KEY", "secret")
|
||||
t.Setenv("DECIDER_KEY", "secret")
|
||||
cfg := &config.Config{Providers: []config.ProviderEntry{{
|
||||
Name: "decider", Kind: "typesafe", BaseURL: "https://decide.example",
|
||||
Models: []string{"system-one"}, APIKeyEnv: "DECIDER_KEY",
|
||||
}}}
|
||||
cfg.Agent.DecisionModel = "decider/system-one"
|
||||
// Load does this for a real config; a hand-built one has to ask.
|
||||
cfg.Providers[0].ResolveAPIKeyForRoot(".")
|
||||
addSystemOne(reg, []string{"system_one"}, cfg, http.DefaultClient)
|
||||
registered, ok := reg.Get("system_one")
|
||||
if !ok {
|
||||
t.Fatal("configured system_one was not registered")
|
||||
t.Fatal("the decision role's backend was not registered")
|
||||
}
|
||||
visible, ok := registered.(tool.ContextualTool)
|
||||
if !ok || !visible.ProviderVisible(context.Background()) {
|
||||
@@ -2680,6 +2688,23 @@ func TestAddBuiltinsBindsConfiguredSystemOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A chat model in the decision role is a model that cannot answer a question
|
||||
// set. Binding it would turn a misconfiguration into a failing turn.
|
||||
func TestAddBuiltinsRefusesAChatModelAsTheDecisionBackend(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
t.Setenv("CHAT_KEY", "secret")
|
||||
cfg := &config.Config{Providers: []config.ProviderEntry{{
|
||||
Name: "chatty", Kind: "openai", BaseURL: "https://chat.example",
|
||||
Models: []string{"gpt-x"}, APIKeyEnv: "CHAT_KEY",
|
||||
}}}
|
||||
cfg.Agent.DecisionModel = "chatty/gpt-x"
|
||||
cfg.Providers[0].ResolveAPIKeyForRoot(".")
|
||||
addSystemOne(reg, []string{"system_one"}, cfg, http.DefaultClient)
|
||||
if _, ok := reg.Get("system_one"); ok {
|
||||
t.Fatal("a chat model was bound as the decision backend")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {
|
||||
dir := robustTempDir(t)
|
||||
home := robustTempDir(t)
|
||||
|
||||
@@ -11,17 +11,24 @@ import (
|
||||
"reasonix/internal/tool/builtin"
|
||||
)
|
||||
|
||||
// addSystemOne wires the decision tool to whichever model the decision role
|
||||
// names. The backend is an ordinary provider entry on a decision wire, so it is
|
||||
// added, keyed and edited exactly where every other model is; nothing here
|
||||
// knows a vendor's name.
|
||||
func addSystemOne(reg *tool.Registry, enabled []string, cfg *config.Config, client *http.Client) {
|
||||
if len(enabled) != 0 && !slices.Contains(enabled, "system_one") {
|
||||
return
|
||||
}
|
||||
spec := builtin.SystemOneSpec{BaseURL: cfg.Tools.SystemOne.BaseURL, Model: cfg.Tools.SystemOne.Model, APIKey: cfg.SystemOneAPIKey}
|
||||
layaConfig := cfg.Tools.SystemOne.Laya
|
||||
if layaConfig.Local {
|
||||
spec.LayaLocal = &laya.LocalClient{Python: layaConfig.Python, Model: layaConfig.Model}
|
||||
spec := builtin.SystemOneSpec{}
|
||||
if entry, ok := cfg.ResolveModel(cfg.Agent.DecisionModel); ok && config.AnswersFor(entry.Kind) == config.AnswersDecision {
|
||||
spec.Name, spec.BaseURL, spec.Model = entry.Name, entry.BaseURL, entry.Model
|
||||
spec.APIKey = func() string { return decisionKey(entry) }
|
||||
}
|
||||
if strings.TrimSpace(layaConfig.HTTPBaseURL) != "" {
|
||||
spec.LayaHTTP = &laya.HTTPClient{HTTP: client, BaseURL: layaConfig.HTTPBaseURL, APIKey: cfg.LayaHTTPAPIKey}
|
||||
// The local runtime is not an endpoint — it runs a Python process and has
|
||||
// neither an address nor a key — so it stays a declared backend rather than
|
||||
// pretending to be a source.
|
||||
if lc := cfg.Tools.SystemOne.Laya; lc.Local {
|
||||
spec.LayaLocal = &laya.LocalClient{Python: lc.Python, Model: lc.Model}
|
||||
}
|
||||
if !builtin.SystemOneConfigured(spec) {
|
||||
return
|
||||
@@ -29,3 +36,18 @@ func addSystemOne(reg *tool.Registry, enabled []string, cfg *config.Config, clie
|
||||
spec.HTTP = client
|
||||
reg.Add(builtin.NewSystemOne(spec))
|
||||
}
|
||||
|
||||
// decisionKey reads the source's key the way every provider does, then falls
|
||||
// back to the process environment. A provider entry resolves from the Reasonix
|
||||
// credential store alone; this setting was documented as reading an exported
|
||||
// variable, and migrating it must not quietly stop honouring one.
|
||||
func decisionKey(entry *config.ProviderEntry) string {
|
||||
if key := strings.TrimSpace(entry.APIKey()); key != "" {
|
||||
return key
|
||||
}
|
||||
name := strings.TrimSpace(entry.APIKeyEnv)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return config.ResolveCredentialForRoot(".", name).Value
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/i18n"
|
||||
)
|
||||
|
||||
// browserCommand opens a page in the session's browser. The terminal cannot
|
||||
// draw it, but the tab is the same tab the agent reads and drives and the same
|
||||
// one a window shows: one browser, whoever opened the page.
|
||||
func (m *chatTUI) browserCommand(input string) {
|
||||
m.echoLocalCommand(input)
|
||||
address := strings.TrimSpace(strings.TrimPrefix(input, strings.Fields(input)[0]))
|
||||
if address == "" {
|
||||
m.notice("usage: /browser <url>")
|
||||
return
|
||||
}
|
||||
tab, err := m.ctrl.BrowserOpen(context.Background(), address, "", true)
|
||||
if err != nil {
|
||||
m.notice(i18n.M.ErrorPrefix + " " + err.Error())
|
||||
return
|
||||
}
|
||||
m.notice(tab.URL)
|
||||
}
|
||||
@@ -4723,6 +4723,8 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
|
||||
case "/switch":
|
||||
m.echoLocalCommand(input)
|
||||
m.runSwitchCommand(input)
|
||||
case "/browser":
|
||||
m.browserCommand(input)
|
||||
case "/mcp":
|
||||
m.echoLocalCommand(input)
|
||||
m.runMCPSubcommand(input)
|
||||
@@ -4735,21 +4737,15 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
|
||||
case "/model":
|
||||
m.echoLocalCommand(input)
|
||||
m.runModelSubcommand(input)
|
||||
if m.pendingModelSwitch != nil {
|
||||
return m.pendingModelSwitch
|
||||
}
|
||||
return m.pendingModelSwitch
|
||||
case "/provider":
|
||||
m.echoLocalCommand(input)
|
||||
m.runProviderCommand(input)
|
||||
if m.pendingModelSwitch != nil {
|
||||
return m.pendingModelSwitch
|
||||
}
|
||||
return m.pendingModelSwitch
|
||||
case "/skill", "/skills":
|
||||
m.echoLocalCommand(input)
|
||||
m.runSkillSubcommand(input)
|
||||
if m.pendingModelSwitch != nil {
|
||||
return m.pendingModelSwitch
|
||||
}
|
||||
return m.pendingModelSwitch
|
||||
case "/hooks":
|
||||
m.echoLocalCommand(input)
|
||||
m.runHooksSubcommand(input)
|
||||
|
||||
@@ -20,7 +20,9 @@ const serveMissingKeyHelperEnv = "REASONIX_TEST_SERVE_MISSING_KEY_HELPER"
|
||||
|
||||
// TestServeStartsWithMissingProviderKey exercises the real runServe lifecycle:
|
||||
// the listener and port file must become available before a Provider key exists,
|
||||
// and the authenticated browser surface must show the setup page.
|
||||
// and the authenticated surface must report the key it is owed. Onboarding is
|
||||
// the built page's, so what the kernel serves is /provider-setup, not a second
|
||||
// page of its own.
|
||||
func TestServeStartsWithMissingProviderKey(t *testing.T) {
|
||||
if os.Getenv(serveMissingKeyHelperEnv) == "1" {
|
||||
// The package TestMain clears path overrides before dispatching tests, so
|
||||
@@ -120,7 +122,7 @@ api_key_env = "REASONIX_TEST_REMOTE_MISSING_KEY"
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
|
||||
resp, err := client.Get("http://" + addr + "/?token=serve-setup-test-token")
|
||||
resp, err := client.Get("http://" + addr + "/provider-setup?token=serve-setup-test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("open missing-key Serve: %v\n%s", err, output.String())
|
||||
}
|
||||
@@ -132,8 +134,8 @@ api_key_env = "REASONIX_TEST_REMOTE_MISSING_KEY"
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("missing-key Serve status = %d, want 200: %s\n%s", resp.StatusCode, body, output.String())
|
||||
}
|
||||
if !bytes.Contains(body, []byte("Reasonix Provider Setup")) {
|
||||
t.Fatalf("missing-key Serve did not show setup page:\n%s", body)
|
||||
if !bytes.Contains(body, []byte(`"required":true`)) || !bytes.Contains(body, []byte("REASONIX_TEST_REMOTE_MISSING_KEY")) {
|
||||
t.Fatalf("missing-key Serve did not report the owed key:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ func builtinSlashSpecs() []builtinSlashSpec {
|
||||
{name: "/rewind", insert: "/rewind", hint: i18n.M.CmdRewind, showInHelp: true},
|
||||
{name: "/tree", insert: "/tree", hint: i18n.M.CmdTree, showInHelp: true},
|
||||
{name: "/branch", insert: "/branch ", hint: i18n.M.CmdBranch, showInHelp: true},
|
||||
{name: "/browser", insert: "/browser ", hint: i18n.M.CmdBrowser, showInHelp: true},
|
||||
{name: "/switch", insert: "/switch ", hint: i18n.M.CmdSwitchBranch, showInHelp: true},
|
||||
{name: "/todo", insert: "/todo", hint: i18n.M.CmdTodo, showInHelp: true},
|
||||
{name: "/mcp", insert: "/mcp", hint: i18n.M.CmdMcp, showInHelp: true},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// apikeyenv.go — where a provider's key is stored, derived from its name.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// APIKeyEnvFor names the credential slot a provider's key lives in, derived
|
||||
// from the provider name so two never share one. It sits beside isCredentialKey
|
||||
// because building a name and judging one are the same rule: a second copy
|
||||
// elsewhere produced "129_API_KEY" for a relay called "129", which the store
|
||||
// then refused with nothing the person could act on.
|
||||
func APIKeyEnvFor(name string) string {
|
||||
stem := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
return r
|
||||
default:
|
||||
return '_'
|
||||
}
|
||||
}, strings.ToUpper(strings.TrimSpace(name)))
|
||||
stem = strings.Trim(stem, "_")
|
||||
if stem == "" {
|
||||
return "CUSTOM_" + fnv1a32Hex(name) + "_API_KEY"
|
||||
}
|
||||
// An environment variable may not open with a digit, and a name that does
|
||||
// is ordinary for a self-hosted relay.
|
||||
if stem[0] >= '0' && stem[0] <= '9' {
|
||||
stem = "CUSTOM_" + stem
|
||||
}
|
||||
return stem + "_API_KEY"
|
||||
}
|
||||
|
||||
func fnv1a32Hex(s string) string {
|
||||
hash := uint32(0x811c9dc5)
|
||||
for _, unit := range utf16.Encode([]rune(strings.TrimSpace(s))) {
|
||||
hash ^= uint32(unit)
|
||||
hash *= 0x01000193
|
||||
}
|
||||
return fmt.Sprintf("%08x", hash)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// A relay named for its port or its address opens with a digit, and an
|
||||
// environment variable may not. The frontend's own copy of this rule did not
|
||||
// know that, so saving the key was refused and onboarding had no way forward.
|
||||
func TestEveryDerivedSlotIsOneTheStoreAccepts(t *testing.T) {
|
||||
for _, name := range []string{"129", "8080-relay", "deepseek", "中转站", " ", "!!!", "my relay"} {
|
||||
got := APIKeyEnvFor(name)
|
||||
if !isCredentialKey(got) {
|
||||
t.Errorf("APIKeyEnvFor(%q) = %q, which the credential store refuses", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two providers must not share a slot, or connecting the second overwrites the
|
||||
// first's key.
|
||||
func TestDifferentProvidersGetDifferentSlots(t *testing.T) {
|
||||
if APIKeyEnvFor("129") == APIKeyEnvFor("130") {
|
||||
t.Fatal("two relays landed in the same credential slot")
|
||||
}
|
||||
if APIKeyEnvFor("deepseek") != "DEEPSEEK_API_KEY" {
|
||||
t.Fatalf("an ordinary name moved: %q", APIKeyEnvFor("deepseek"))
|
||||
}
|
||||
}
|
||||
@@ -1045,7 +1045,11 @@ type AgentConfig struct {
|
||||
// TriageModel answers the small classifications the static tables come up
|
||||
// short on (is this unrecognized command read-only?). Empty falls back to
|
||||
// subagent_model, then the main model — set it to point them somewhere cheap.
|
||||
TriageModel string `toml:"triage_model"`
|
||||
TriageModel string `toml:"triage_model"`
|
||||
// DecisionModel names the backend system_one asks. It is on a decision
|
||||
// wire, so it never falls back to the main model: a chat model has no
|
||||
// answer for a question set, and falling back would hide that.
|
||||
DecisionModel string `toml:"decision_model"`
|
||||
SubagentEffort string `toml:"subagent_effort"`
|
||||
SubagentEfforts map[string]string `toml:"subagent_efforts"`
|
||||
MaxSubagentDepth int `toml:"max_subagent_depth"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
@@ -318,10 +319,15 @@ func storeCredentialAssignmentsLocked(assignments map[string]string) (string, er
|
||||
return UserCredentialsPath(), nil
|
||||
}
|
||||
|
||||
// ErrInvalidCredentialKey is refused when a slot name is not one an environment
|
||||
// variable may carry. Callers tell it apart to say which field the person has
|
||||
// to change: the name a slot is derived from, never the key they pasted.
|
||||
var ErrInvalidCredentialKey = errors.New("credential slot name is not a usable environment variable")
|
||||
|
||||
func SetCredential(key, value string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if !isCredentialKey(key) {
|
||||
return "", fmt.Errorf("invalid credential key %q", key)
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidCredentialKey, key)
|
||||
}
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return "", fmt.Errorf("credential value for %s contains a newline", key)
|
||||
@@ -336,7 +342,7 @@ func SetCredential(key, value string) (string, error) {
|
||||
func SetCredentialIfRevision(key, value, expectedRevision string) (string, bool, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if !isCredentialKey(key) {
|
||||
return "", false, fmt.Errorf("invalid credential key %q", key)
|
||||
return "", false, fmt.Errorf("%w: %q", ErrInvalidCredentialKey, key)
|
||||
}
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return "", false, fmt.Errorf("credential value for %s contains a newline", key)
|
||||
|
||||
+22
-13
@@ -220,19 +220,7 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
normalizeLegacyEffort(cfg)
|
||||
cfg.ignoredLegacyStepLimits = normalizeLegacyAgentStepLimits(cfg)
|
||||
normalizeRetiredAutoPlan(cfg)
|
||||
normalizeLegacyMCPTiers(cfg)
|
||||
normalizeLegacyStepFunBaseURLs(cfg)
|
||||
upgradeShippedPresets(cfg)
|
||||
normalizeLegacyMimoCustomProviders(cfg)
|
||||
normalizeLegacyProviderFields(cfg)
|
||||
normalizeDesktopOfficialProviderAccess(cfg)
|
||||
normalizeOfficialDeepSeekModels(cfg)
|
||||
migrateBillingDisplayCurrency(cfg)
|
||||
freezeProviderBillingCurrencies(cfg)
|
||||
applyOfficialDefaultPricing(cfg)
|
||||
backfillDeepSeekOfficialPrices(cfg)
|
||||
normalizeEffortConfig(cfg)
|
||||
backfillDeepSeekPro(cfg)
|
||||
bringConfigForward(cfg)
|
||||
if userDefaultModelExplicit {
|
||||
restoreUnresolvableProjectDefaultModel(cfg, userDefaultModel)
|
||||
}
|
||||
@@ -731,6 +719,7 @@ func normalizeConfigForEdit(cfg *Config) bool {
|
||||
changed = normalizeRetiredMultiThresholdCompaction(cfg) || changed
|
||||
normalizeLegacyMCPTiers(cfg)
|
||||
changed = normalizeLegacyStepFunBaseURLs(cfg) || changed
|
||||
changed = migrateSystemOneToDecisionRole(cfg) || changed
|
||||
changed = upgradeShippedPresets(cfg) || changed
|
||||
changed = normalizeLegacyMimoCustomProviders(cfg) || changed
|
||||
changed = normalizeLegacyProviderFields(cfg) || changed
|
||||
@@ -2003,3 +1992,23 @@ func retargetDesktopOfficialRef(ref string, access map[string]bool) string {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
|
||||
// bringConfigForward applies every in-memory upgrade a loaded config gets:
|
||||
// retired keys normalised, shipped shapes moved forward, prices backfilled.
|
||||
// One list, because the edit path has to apply exactly the same set.
|
||||
func bringConfigForward(cfg *Config) {
|
||||
normalizeLegacyMCPTiers(cfg)
|
||||
normalizeLegacyStepFunBaseURLs(cfg)
|
||||
migrateSystemOneToDecisionRole(cfg)
|
||||
upgradeShippedPresets(cfg)
|
||||
normalizeLegacyMimoCustomProviders(cfg)
|
||||
normalizeLegacyProviderFields(cfg)
|
||||
normalizeDesktopOfficialProviderAccess(cfg)
|
||||
normalizeOfficialDeepSeekModels(cfg)
|
||||
migrateBillingDisplayCurrency(cfg)
|
||||
freezeProviderBillingCurrencies(cfg)
|
||||
applyOfficialDefaultPricing(cfg)
|
||||
backfillDeepSeekOfficialPrices(cfg)
|
||||
normalizeEffortConfig(cfg)
|
||||
backfillDeepSeekPro(cfg)
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
// keeping a list of its own.
|
||||
type Protocol struct {
|
||||
Kind string
|
||||
// Answers names what a model on this wire produces, which is what decides
|
||||
// the jobs it may be given. A decision wire returns verdicts to a question
|
||||
// set and holds no conversation, so it is offered for that role alone.
|
||||
Answers Answers
|
||||
// Discovery names the model-listing shape an endpoint answers under.
|
||||
// Protocols sharing one value are indistinguishable to a probe.
|
||||
Discovery string
|
||||
@@ -31,9 +35,12 @@ type Protocol struct {
|
||||
// protocols is ordered as a chooser should offer them: the common wire first,
|
||||
// then the ones a particular endpoint adds on top of it.
|
||||
var protocols = []Protocol{
|
||||
{Kind: "openai", Discovery: "openai", ReasoningParams: true},
|
||||
{Kind: "responses", Discovery: "openai", ServerWebSearch: true, StatefulContinuation: true},
|
||||
{Kind: "anthropic", Discovery: "anthropic", ServerWebSearch: true},
|
||||
{Kind: "openai", Answers: AnswersChat, Discovery: "openai", ReasoningParams: true},
|
||||
{Kind: "responses", Answers: AnswersChat, Discovery: "openai", ServerWebSearch: true, StatefulContinuation: true},
|
||||
{Kind: "anthropic", Answers: AnswersChat, Discovery: "anthropic", ServerWebSearch: true},
|
||||
// No Discovery: the decision API has no model listing, so the model id is
|
||||
// declared rather than probed for.
|
||||
{Kind: "typesafe", Answers: AnswersDecision},
|
||||
}
|
||||
|
||||
// protocolAliases are registry keys that resolve to a protocol above but are
|
||||
@@ -41,6 +48,25 @@ var protocols = []Protocol{
|
||||
// same capabilities the wire actually has.
|
||||
var protocolAliases = map[string]string{"dashscope-responses": "responses"}
|
||||
|
||||
// Answers is what a wire produces. Declared per protocol, never inferred from
|
||||
// a kind's spelling.
|
||||
type Answers string
|
||||
|
||||
const (
|
||||
AnswersChat Answers = "chat"
|
||||
AnswersDecision Answers = "decision"
|
||||
)
|
||||
|
||||
// AnswersFor reports what a configured kind produces. An unknown kind answers
|
||||
// chat: it is what every wire before decision backends produced, so a config
|
||||
// written against an older build keeps the meaning it was written with.
|
||||
func AnswersFor(kind string) Answers {
|
||||
if p, ok := ProtocolFor(kind); ok && p.Answers != "" {
|
||||
return p.Answers
|
||||
}
|
||||
return AnswersChat
|
||||
}
|
||||
|
||||
// Protocols returns the wire formats a chooser may offer, in display order.
|
||||
func Protocols() []Protocol {
|
||||
return slices.Clone(protocols)
|
||||
|
||||
@@ -11,13 +11,19 @@ import (
|
||||
)
|
||||
|
||||
// The catalog is what every frontend offers and the registry is what the kernel
|
||||
// can actually build. A kind in one and not the other is a wire nobody can pick
|
||||
// or a menu entry that fails on the first turn.
|
||||
// can build a conversation on. A chat kind in one and not the other is a wire
|
||||
// nobody can pick or a menu entry that fails on the first turn. A decision wire
|
||||
// holds no conversation, so a factory for it would be a chat client that cannot
|
||||
// chat: it is built by the decision path instead, which internal/boot holds.
|
||||
func TestCatalogAndRegistryDescribeTheSameWires(t *testing.T) {
|
||||
registered := provider.Kinds()
|
||||
for _, p := range Protocols() {
|
||||
if !slices.Contains(registered, p.Kind) {
|
||||
t.Errorf("catalog offers %q, which no provider factory registers", p.Kind)
|
||||
chat := p.Answers != AnswersDecision
|
||||
if chat && !slices.Contains(registered, p.Kind) {
|
||||
t.Errorf("catalog offers chat wire %q, which no provider factory registers", p.Kind)
|
||||
}
|
||||
if !chat && slices.Contains(registered, p.Kind) {
|
||||
t.Errorf("decision wire %q has a chat factory, so it can be chosen as a main model", p.Kind)
|
||||
}
|
||||
}
|
||||
for _, kind := range registered {
|
||||
@@ -27,6 +33,18 @@ func TestCatalogAndRegistryDescribeTheSameWires(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every protocol says what it produces, or a chooser has to guess from the kind.
|
||||
func TestEveryProtocolDeclaresWhatItAnswers(t *testing.T) {
|
||||
for _, p := range Protocols() {
|
||||
if p.Answers != AnswersChat && p.Answers != AnswersDecision {
|
||||
t.Errorf("protocol %q answers %q, which is neither chat nor decision", p.Kind, p.Answers)
|
||||
}
|
||||
}
|
||||
if AnswersFor("nonesuch") != AnswersChat {
|
||||
t.Error("an unknown kind must read as chat, which is what every wire was before decision backends")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolsDiscoveredAsGroupsWiresByTheirListing(t *testing.T) {
|
||||
openai := ProtocolsDiscoveredAs("openai")
|
||||
if !slices.Contains(openai, "openai") || !slices.Contains(openai, "responses") {
|
||||
|
||||
@@ -41,6 +41,11 @@ func renderAgentDelegation(b *strings.Builder, c *Config) {
|
||||
} else {
|
||||
b.WriteString("# guardian_temperature = 0.0 # the reviewer's own sampling temperature\n")
|
||||
}
|
||||
if c.Agent.DecisionModel != "" {
|
||||
fmt.Fprintf(b, "decision_model = %q # the system_one backend; a decision wire, never the main model\n", c.Agent.DecisionModel)
|
||||
} else {
|
||||
b.WriteString("# decision_model = \"typesafe/system-one\" # optional: the system_one backend\n")
|
||||
}
|
||||
if c.Agent.TriageModel != "" {
|
||||
fmt.Fprintf(b, "triage_model = %q # small classifications; falls back to subagent, then main\n", c.Agent.TriageModel)
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,7 @@ func (c *Config) roleModelRefTargets() []*string {
|
||||
return []*string{
|
||||
&c.Agent.PlannerModel, &c.Agent.SubagentModel, &c.Agent.VisionModel,
|
||||
&c.Agent.GuardianModel, &c.Agent.RecoveryModel, &c.Agent.TriageModel,
|
||||
&c.Agent.DecisionModel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// systemone_migrate.go — the decision backend moves onto the general mechanism.
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/typesafe"
|
||||
)
|
||||
|
||||
// migrateSystemOneToDecisionRole turns the decision settings into what every
|
||||
// other model already is: a source with an address and a key, and a role naming
|
||||
// one. Both halves were special — a hosted endpoint and a "gateway" that spoke
|
||||
// the same wire at another address — so both become ordinary entries. It runs
|
||||
// once: a second pass finds the fields cleared and the sources already there.
|
||||
func migrateSystemOneToDecisionRole(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
one := c.Tools.SystemOne
|
||||
hosted := anySet(one.BaseURL, one.Model, one.APIKeyEnv)
|
||||
gateway := strings.TrimSpace(one.Laya.HTTPBaseURL) != ""
|
||||
if !hosted && !gateway {
|
||||
return false
|
||||
}
|
||||
if hosted {
|
||||
c.adoptDecisionSource("typesafe", one.BaseURL, typesafe.DefaultBaseURL, one.Model, "system-one", one.APIKeyEnv, "TYPESAFE_API_KEY")
|
||||
}
|
||||
if gateway {
|
||||
// The gateway never carried a model of its own: the old client sent
|
||||
// "auto" whatever the Laya section said, and that field belongs to the
|
||||
// local runtime, which is staying where it is.
|
||||
c.adoptDecisionSource("laya", one.Laya.HTTPBaseURL, "", "auto", "auto", one.Laya.HTTPAPIKeyEnv, "")
|
||||
}
|
||||
c.Tools.SystemOne.BaseURL = ""
|
||||
c.Tools.SystemOne.Model = ""
|
||||
c.Tools.SystemOne.APIKeyEnv = ""
|
||||
c.Tools.SystemOne.Laya.HTTPBaseURL = ""
|
||||
c.Tools.SystemOne.Laya.HTTPAPIKeyEnv = ""
|
||||
return true
|
||||
}
|
||||
|
||||
// adoptDecisionSource adds one decision endpoint and points the role at it when
|
||||
// the role is still unset. A name already taken is left alone: the person's own
|
||||
// source outranks anything a migration would write over it.
|
||||
func (c *Config) adoptDecisionSource(name, baseURL, defaultURL, model, defaultModel, keyEnv, defaultKeyEnv string) {
|
||||
if _, taken := c.Provider(name); taken {
|
||||
return
|
||||
}
|
||||
entry := ProviderEntry{
|
||||
Name: name,
|
||||
Kind: "typesafe",
|
||||
BaseURL: firstSet(baseURL, defaultURL),
|
||||
Models: []string{firstSet(model, defaultModel)},
|
||||
APIKeyEnv: firstSet(keyEnv, defaultKeyEnv),
|
||||
}
|
||||
if strings.TrimSpace(entry.BaseURL) == "" {
|
||||
return
|
||||
}
|
||||
c.Providers = append(c.Providers, entry)
|
||||
if strings.TrimSpace(c.Agent.DecisionModel) == "" {
|
||||
c.Agent.DecisionModel = entry.Name + "/" + entry.Models[0]
|
||||
}
|
||||
}
|
||||
|
||||
func anySet(values ...string) bool {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstSet(values ...string) string {
|
||||
for _, v := range values {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// The decision backend was its own config section with its own vendor fields.
|
||||
// It becomes an ordinary source and a role, and an installed config has to
|
||||
// arrive there without the person retyping an address or a key.
|
||||
func TestSystemOneSettingsBecomeASourceAndARole(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.Tools.SystemOne.BaseURL = "https://decide.example"
|
||||
cfg.Tools.SystemOne.Model = "system-one"
|
||||
cfg.Tools.SystemOne.APIKeyEnv = "MY_DECISION_KEY"
|
||||
|
||||
if !migrateSystemOneToDecisionRole(cfg) {
|
||||
t.Fatal("a configured backend was not migrated")
|
||||
}
|
||||
entry, ok := cfg.Provider("typesafe")
|
||||
if !ok {
|
||||
t.Fatal("no source was written for the configured backend")
|
||||
}
|
||||
if entry.Kind != "typesafe" || entry.BaseURL != "https://decide.example" || entry.APIKeyEnv != "MY_DECISION_KEY" {
|
||||
t.Fatalf("source = %#v", entry)
|
||||
}
|
||||
if !entry.HasModel("system-one") {
|
||||
t.Fatalf("source lists %v, want the configured model", entry.ModelList())
|
||||
}
|
||||
if cfg.Agent.DecisionModel != "typesafe/system-one" {
|
||||
t.Fatalf("decision role = %q", cfg.Agent.DecisionModel)
|
||||
}
|
||||
if cfg.Tools.SystemOne.BaseURL != "" || cfg.Tools.SystemOne.APIKeyEnv != "" {
|
||||
t.Fatal("the retired section still carries the settings it handed over")
|
||||
}
|
||||
if migrateSystemOneToDecisionRole(cfg) {
|
||||
t.Fatal("the migration ran a second time on an already-migrated config")
|
||||
}
|
||||
}
|
||||
|
||||
// The gateway was a second backend with its own client and its own form. It
|
||||
// speaks the same wire at another address, so it arrives as another source.
|
||||
func TestASelfHostedGatewayBecomesAnOrdinarySource(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.Tools.SystemOne.Laya.HTTPBaseURL = "http://127.0.0.1:8000"
|
||||
cfg.Tools.SystemOne.Laya.HTTPAPIKeyEnv = "GATEWAY_KEY"
|
||||
cfg.Tools.SystemOne.Laya.Local = true
|
||||
cfg.Tools.SystemOne.Laya.Python = "python3"
|
||||
|
||||
if !migrateSystemOneToDecisionRole(cfg) {
|
||||
t.Fatal("a configured gateway was not migrated")
|
||||
}
|
||||
entry, ok := cfg.Provider("laya")
|
||||
if !ok || entry.BaseURL != "http://127.0.0.1:8000" || entry.APIKeyEnv != "GATEWAY_KEY" {
|
||||
t.Fatalf("gateway source = %#v, %v", entry, ok)
|
||||
}
|
||||
if cfg.Agent.DecisionModel != "laya/auto" {
|
||||
t.Fatalf("decision role = %q", cfg.Agent.DecisionModel)
|
||||
}
|
||||
// The local runtime is not an endpoint, so it stays where it is.
|
||||
if !cfg.Tools.SystemOne.Laya.Local || cfg.Tools.SystemOne.Laya.Python != "python3" {
|
||||
t.Fatal("the local runtime was carried off with the gateway")
|
||||
}
|
||||
}
|
||||
|
||||
// A source the person added themselves outranks anything a migration writes.
|
||||
func TestMigrationLeavesANameThePersonAlreadyUsed(t *testing.T) {
|
||||
cfg := &Config{Providers: []ProviderEntry{{Name: "typesafe", Kind: "openai", BaseURL: "https://mine.example"}}}
|
||||
cfg.Tools.SystemOne.BaseURL = "https://decide.example"
|
||||
|
||||
migrateSystemOneToDecisionRole(cfg)
|
||||
entry, _ := cfg.Provider("typesafe")
|
||||
if entry.BaseURL != "https://mine.example" || entry.Kind != "openai" {
|
||||
t.Fatalf("the person's own source was overwritten: %#v", entry)
|
||||
}
|
||||
if cfg.Agent.DecisionModel != "" {
|
||||
t.Fatalf("decision role = %q, want none: nothing was adopted", cfg.Agent.DecisionModel)
|
||||
}
|
||||
}
|
||||
|
||||
// An unconfigured install has nothing to move and must not grow a source.
|
||||
func TestMigrationDoesNothingWithoutAConfiguredBackend(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if migrateSystemOneToDecisionRole(cfg) || len(cfg.Providers) != 0 {
|
||||
t.Fatal("an empty config grew a decision source")
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,9 @@ type Status interface {
|
||||
Jobs() []jobs.View
|
||||
Todos() []evidence.TodoItem
|
||||
BrowserTabs() []browser.TabInfo
|
||||
// One browser, one list of tabs: what the window opens the agent can drive,
|
||||
// and what the agent opens the window can show.
|
||||
BrowserOpen(ctx context.Context, rawURL, tabID string, newTab bool) (browser.TabInfo, error)
|
||||
}
|
||||
|
||||
// SessionPersistence covers snapshotting a session and tearing down its on-disk
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user