feat(project): per-project new-chat defaults (agent, workspace, approval, thinking, model) (#191)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 22:52:34 +08:00
committed by GitHub
parent 7cc5c30e71
commit 89435bf62a
27 changed files with 1980 additions and 638 deletions
+43
View File
@@ -360,6 +360,49 @@ export interface ModelTestResponse {
message?: string;
}
/**
* PUT /api/projects/:p/models/default (owner): narrow default-model switch — flips the same
* top-level `default_model` the models page's whole-table PUT writes, without resending the
* table (and thus without touching credentials). The pair must name a configured model
* entry, exactly like the whole-table route's defaultModel validation.
*/
export interface DefaultModelUpdateRequest {
provider: string;
modelId: string;
}
/** Response mirrors what GET models reports as `defaultModel`. */
export interface DefaultModelResponse {
defaultModel: ModelRefDto;
}
// ---------------------------------------------------------------------------
// New-chat defaults (the `[default_chat]` block of .project_config.toml)
// ---------------------------------------------------------------------------
/**
* Per-Project new-chat defaults: prefill for the chat draft page. Every key is optional —
* an absent key means "not set" (the pre-existing behavior). Serves as the GET response,
* the PUT request body (whole-block replace: an omitted key clears it) and the PUT
* response (the stored block). The default MODEL is deliberately not here: it stays the
* top-level `default_model` served/written via the models routes (single-sourced with the
* models page).
*/
export interface ChatDefaultsDto {
/** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */
agentId?: string;
/** Prefilled Workspace directory; absent/empty = auto temp directory. */
workspace?: string;
/** Prefilled approval mode; absent = the built-in "allow-all". */
approvalMode?: ApprovalMode;
/**
* Fallback thinking level for Agents whose config has no explicit `model.thinking_level`
* (resolution chain: Agent explicit > this project default > built-in "medium"). Never
* "none" — only the four selectable tiers.
*/
thinkingLevel?: Exclude<ThinkingLevelName, "none">;
}
// ---------------------------------------------------------------------------
// Vault environment variables (Agent-level: agent_state/.vault.toml)
// ---------------------------------------------------------------------------
+2
View File
@@ -38,6 +38,7 @@ import { eventsRoutes, userChannelKey } from "./http/routes/events.js";
import { projectsRoutes } from "./http/routes/projects.js";
import { membersRoutes } from "./http/routes/members.js";
import { modelsRoutes } from "./http/routes/models.js";
import { chatDefaultsRoutes } from "./http/routes/chat-defaults.js";
import { vaultRoutes } from "./http/routes/vault.js";
import { scheduleRoutes } from "./http/routes/schedules.js";
import { benchmarksRoutes } from "./http/routes/benchmarks.js";
@@ -378,6 +379,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
app.route("/api/projects", projectsRoutes(deps));
app.route("/api/projects/:projectId/members", membersRoutes(deps));
app.route("/api/projects/:projectId/models", modelsRoutes(deps));
app.route("/api/projects/:projectId/chat-defaults", chatDefaultsRoutes(deps));
app.route("/api/projects/:projectId/agents", agentsRoutes(deps));
app.route("/api/projects/:projectId/dirs", dirsRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps));
@@ -0,0 +1,65 @@
/**
* New-chat defaults routes: GET|PUT /api/projects/:p/chat-defaults (the `[default_chat]`
* block of .project_config.toml). Any member can read (the draft page prefills from it);
* only the owner can replace it. PUT is declarative whole-block replace: an omitted key
* clears it, an empty body removes the block. The default MODEL is not part of this block —
* it stays the top-level `default_model` maintained via the models routes.
*/
import { Hono } from "hono";
import { DEFAULT_CHAT_THINKING_LEVELS, isValidId } from "@prismshadow/penguin-core";
import type { ChatDefaultsDto } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { HttpError } from "../errors.js";
import { optionalEnum, optionalString, readJson, requireValidId } from "../validate.js";
import { APPROVAL_MODES } from "./sessions.js";
import type { AppDeps } from "../../app.js";
export function chatDefaultsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Defensive id validation (FD-4).
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
return c.json(await deps.projectConfigService.getChatDefaults(projectId));
});
app.put("/", async (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const body = await readJson(c);
const req: ChatDefaultsDto = {};
// agentId must reference an existing Agent of this Project (a stale default would make
// every prefilled draft 404). isValidId first: existence is checked by path
// (system_config.yaml presence, the same rule the agent routes use), so a junk id must
// never reach path construction.
const agentId = optionalString(body, "agentId", { minLen: 1, maxLen: 64, label: "agentId" });
if (agentId !== undefined) {
if (!isValidId(agentId) || !(await deps.agentConfigService.exists(projectId, agentId))) {
throw new HttpError(
400,
"unknown_agent",
`agentId does not reference an Agent of this Project: ${agentId}.`,
);
}
req.agentId = agentId;
}
// Not validated as an existing directory on purpose: the default is a prefill, and the
// directory is (re)checked when a Session is actually created. "" = clear (auto temp).
const workspace = optionalString(body, "workspace", { maxLen: 4096, label: "workspace" });
if (workspace !== undefined && workspace !== "") req.workspace = workspace;
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
if (approvalMode !== undefined) req.approvalMode = approvalMode;
// Only the four selectable tiers — "none" is rejected (never offered as a default).
const thinkingLevel = optionalEnum(body, "thinkingLevel", DEFAULT_CHAT_THINKING_LEVELS);
if (thinkingLevel !== undefined) req.thinkingLevel = thinkingLevel;
return c.json(await deps.projectConfigService.setChatDefaults(projectId, req));
});
return app;
}
+21 -1
View File
@@ -1,11 +1,13 @@
/**
* Model & credential config routes:
* GET|PUT /api/projects/:p/models, POST /api/projects/:p/models/test (the model reference
* GET|PUT /api/projects/:p/models, PUT /api/projects/:p/models/default,
* POST /api/projects/:p/models/test (the model reference
* `(provider, modelId)` is sent as a pair in the request body, avoiding URL-encoding
* issues). Any member can read (api_key is masked); only the owner can modify or test.
*/
import { Hono } from "hono";
import type {
DefaultModelResponse,
ModelRefDto,
ModelsUpdateRequest,
ModelTestRequest,
@@ -168,6 +170,24 @@ export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
return c.json(res);
});
// Narrow default-model switch (owner): flips the same top-level `default_model` the
// whole-table PUT above maintains, without resending the table — project settings can
// change the default without carrying credentials. The pair must name a configured
// entry (400 otherwise, same rule as the whole-table route's defaultModel). No runtime
// invalidation and no credentials_updated: existing Sessions pin their model at
// creation, and no credential changes here.
app.put("/default", async (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const body = await readJson(c);
const ref: ModelRefDto = {
provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
};
const defaultModel = await deps.projectConfigService.setDefaultModelRef(projectId, ref);
return c.json({ defaultModel } satisfies DefaultModelResponse);
});
// Connectivity test (owner): the model reference `(provider, modelId)` is sent as a pair
// in the request body; sends one minimal request using that model's config. May include
// not-yet-saved apiKey / baseUrl / clientType — when the model isn't in the config yet
+2 -1
View File
@@ -61,7 +61,8 @@ const SESSION_TITLE_MAX = 120;
const STAT_MAX_PATHS = 100;
const STAT_MAX_PATH_LEN = 512;
const APPROVAL_MODES: readonly ApprovalMode[] = [
/** The four approval modes (shared with the chat-defaults route's validation). */
export const APPROVAL_MODES: readonly ApprovalMode[] = [
"allow-all",
"deny-all",
"read-only",
@@ -19,6 +19,8 @@ import fs from "node:fs/promises";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import {
CHAT_APPROVAL_MODES,
DEFAULT_CHAT_THINKING_LEVELS,
GenerativeModel,
catalogEntryFor,
defaultProjectConfig,
@@ -29,6 +31,7 @@ import {
} from "@prismshadow/penguin-core";
import type { LLMOutcome, ModelRef, OmniMessage } from "@prismshadow/penguin-core";
import type {
ChatDefaultsDto,
ModelInfo,
ModelPricingDto,
ModelRefDto,
@@ -224,6 +227,77 @@ export class ProjectConfigService {
return optRef(raw.default_model);
}
/**
* Sets the default Model to an already-configured entry (the narrow
* PUT /models/default route): rewrites only the top-level `default_model` — the SAME key
* the models page's whole-table PUT maintains, so the two surfaces stay single-sourced —
* preserving every other field via read-modify-write. The pair must name an entry in
* `models` (the identical rule updateModels applies to `defaultModel`); a reference
* outside the config is a 400, since createSession would error on it immediately. No
* runtime invalidation: existing Sessions pin their model at creation, and this route
* never touches credentials.
*/
async setDefaultModelRef(projectId: string, ref: ModelRefDto): Promise<ModelRefDto> {
const raw = await this.readRaw(projectId);
if (!asArray(raw.models).some((m) => entryMatches(m, ref.provider, ref.modelId))) {
throw badRequest(
`defaultModel must be included in models: ${showRef(ref.provider, ref.modelId)}.`,
);
}
await this.writeRaw(projectId, {
...raw,
default_model: { provider: ref.provider, model_id: ref.modelId },
});
return { provider: ref.provider, modelId: ref.modelId };
}
/**
* New-chat defaults (`[default_chat]`): read leniently — same tolerance as core's
* loadProjectConfig (an invalid value drops that key; a missing/malformed block reads as
* empty). Members may read; only the fields present in the file appear in the DTO.
*/
async getChatDefaults(projectId: string): Promise<ChatDefaultsDto> {
const raw = await this.readRaw(projectId);
const t = asTable(raw.default_chat);
const agentId = optStr(t.agent_id);
const workspace = optStr(t.workspace);
const approval = optStr(t.approval_mode);
const thinking = optStr(t.thinking_level);
return {
...(agentId !== undefined ? { agentId } : {}),
...(workspace !== undefined ? { workspace } : {}),
...(approval !== undefined && (CHAT_APPROVAL_MODES as readonly string[]).includes(approval)
? { approvalMode: approval as ChatDefaultsDto["approvalMode"] }
: {}),
...(thinking !== undefined &&
(DEFAULT_CHAT_THINKING_LEVELS as readonly string[]).includes(thinking)
? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] }
: {}),
};
}
/**
* Replaces the whole `[default_chat]` block (declarative PUT: an omitted key clears it;
* an empty request removes the block entirely, restoring the pre-existing behavior).
* Field validation (enums / agent existence) happens at the route; this is a
* read-modify-write like setName — every other field (models, credentials, name, the
* default model) is preserved. Returns the stored block as re-read from disk.
*/
async setChatDefaults(projectId: string, req: ChatDefaultsDto): Promise<ChatDefaultsDto> {
const raw = await this.readRaw(projectId);
const block: RawTable = {
...(req.agentId !== undefined ? { agent_id: req.agentId } : {}),
...(req.workspace !== undefined ? { workspace: req.workspace } : {}),
...(req.approvalMode !== undefined ? { approval_mode: req.approvalMode } : {}),
...(req.thinkingLevel !== undefined ? { thinking_level: req.thinkingLevel } : {}),
};
const next: RawTable = { ...raw };
if (Object.keys(block).length > 0) next.default_chat = block;
else delete next.default_chat;
await this.writeRaw(projectId, next);
return this.getChatDefaults(projectId);
}
/** Pricing lookup for usage-recorder: the current pricing for this paired reference (undefined if none -> cost is NULL). */
async getPricing(
projectId: string,