Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
2026-05-22 21:27:25 +00:00
4 changed files with 22 additions and 779 deletions
+2 -4
View File
@@ -420,8 +420,7 @@ async def update_tool_type(
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
if tool_type.is_builtin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
# Built-in tool types can now be modified
update_data = data.model_dump(exclude_unset=True)
@@ -642,8 +641,7 @@ async def delete_tool_type(
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
if tool_type.is_builtin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
# Built-in tool types can now be deleted
await session.delete(tool_type)
await session.commit()
-354
View File
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type ToolConfig,
} from "../api/tool_configs";
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const [formData, setFormData] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadData = useCallback(async () => {
try {
const [typesData, configsData] = await Promise.all([
listToolTypes(),
listToolConfigs(),
]);
setToolTypes(typesData);
setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [selectedToolType]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
const data = {
tool_type_id: selectedToolType,
key: formData.key,
value: formData.value,
config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) {
await updateToolConfig(editingConfig.id, data);
} else {
await createToolConfig(data);
}
setSaveStatus("saved");
setShowForm(false);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
await loadData();
} catch {
setSaveStatus("error");
}
};
const handleEdit = (config: ToolConfig) => {
setEditingConfig(config);
setFormData({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
});
setSelectedToolType(config.tool_type_id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
// Error handled by UI state
}
};
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType
);
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
if (status === "loading") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<p className="muted">Loading...</p>
</section>
);
}
if (status === "error") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
</div>
{/* Tool Type Selector */}
<div className="card">
<label htmlFor="tool-type-select">Select Tool</label>
<select
id="tool-type-select"
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setShowForm(false);
setEditingConfig(null);
}}
className="form-input"
>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interface: {selectedTool.interface_type}
</p>
)}
</div>
{/* Config List */}
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
}}
type="button"
>
<Icon name="add" size="sm" />
Add Config
</button>
</div>
{filteredConfigs.length === 0 ? (
<p className="muted">No configurations for this tool yet.</p>
) : (
<div className="stack" style={{ gap: "0.5rem" }}>
{filteredConfigs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{config.config_type}
</span>
</div>
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => handleEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void handleDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => {
setShowForm(false);
setEditingConfig(null);
}}
>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
)}
</section>
);
};
-383
View File
@@ -1,383 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import { Icon } from "../components/icon";
import type { ToolType } from "../api/tool_types";
type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
const [formName, setFormName] = useState("");
const [formDisplayName, setFormDisplayName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formCategory, setFormCategory] = useState("");
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
const [formPort, setFormPort] = useState("");
const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadToolTypes = useCallback(async () => {
setStatus("loading");
try {
const data = await listToolTypes();
setToolTypes(data);
setStatus("ready");
} catch {
setToolTypes([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadToolTypes();
}, [loadToolTypes]);
const openCreate = () => {
setFormName("");
setFormDisplayName("");
setFormDescription("");
setFormCategory("");
setFormInterfaces([]);
setFormPort("");
setFormTemplate("");
setFormVariables("");
setFormError(null);
setEditingToolType(null);
setDialogMode("create");
};
const openEdit = (toolType: ToolType) => {
setFormName(toolType.name);
setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? "");
setFormCategory(toolType.category ?? "");
setFormInterfaces(toolType.interface_type ? [toolType.interface_type] : []);
setFormPort(toolType.default_port?.toString() ?? "");
setFormTemplate(toolType.compose_template ?? "");
setFormVariables(toolType.required_variables.join(", "));
setFormError(null);
setEditingToolType(toolType);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingToolType(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
setFormError("Name, display name, and compose template are required");
return;
}
if (!formPort.trim() || isNaN(Number(formPort))) {
setFormError("Default port is required and must be a number");
return;
}
const variables = formVariables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
try {
if (dialogMode === "create") {
const input: CreateToolTypeRequest = {
name: formName.trim(),
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web",
requires_port: formInterfaces.includes("web"),
default_port: Number(formPort),
definition_type: "compose",
compose_template: formTemplate.trim(),
required_variables: variables,
};
await createToolType(input);
} else if (dialogMode === "edit" && editingToolType) {
const input: UpdateToolTypeRequest = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web",
requires_port: formInterfaces.includes("web"),
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
await updateToolType(editingToolType.id, input);
}
closeDialog();
await loadToolTypes();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
setFormError(detail);
}
};
const handleDelete = async (id: string) => {
try {
await deleteToolType(id);
setDeleteConfirmId(null);
await loadToolTypes();
} catch {
alert("Failed to delete tool type");
}
};
if (status === "loading") {
return (
<div className="container">
<p>Loading tool types...</p>
</div>
);
}
if (status === "error") {
return (
<div className="container">
<p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
);
}
return (
<div className="container">
<div className="page-header" style={{ marginBottom: "1rem" }}>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? (
<p>No tool types found.</p>
) : (
<div className="card-grid">
{toolTypes.map((toolType) => (
<div key={toolType.id} className="card">
<div className="card-header">
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interface_type && (
<span>Interface: {toolType.interface_type}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
<Icon name="edit" size="sm" />
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
)}
</div>
{deleteConfirmId === toolType.id && (
<div className="dialog-overlay">
<div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
<Icon name="delete" size="sm" />
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name (unique identifier)</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
disabled={dialogMode === "edit"}
placeholder="e.g., code-server"
/>
</div>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="e.g., VS Code Server"
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label>Category</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("web")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
}
}}
/>
Web
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("terminal")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "terminal"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
}
}}
/>
Terminal
</label>
</div>
</div>
<div className="form-group">
<label>Default Port *</label>
<input
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443"
required
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea
value={formTemplate}
onChange={(e) => setFormTemplate(e.target.value)}
rows={10}
placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..."
/>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={formVariables}
onChange={(e) => setFormVariables(e.target.value)}
placeholder="REPO_PATH, TOOL_NAME"
/>
</div>
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Update
</>
)}
</button>
<button type="button" onClick={closeDialog} className="button-secondary">
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+20 -38
View File
@@ -561,44 +561,26 @@ export const ToolWorkshopPage = () => {
{toolType.category || "Uncategorized"} · {toolType.interface_type === "web" ? `Port ${toolType.default_port}` : "Terminal"}
</div>
</div>
{toolType.is_builtin && (
<span
style={{
fontSize: "0.625rem",
textTransform: "uppercase",
padding: "0.125rem 0.375rem",
borderRadius: "9999px",
background: selectedToolTypeId === toolType.id ? "rgba(255,255,255,0.25)" : "var(--muted)",
color: selectedToolTypeId === toolType.id ? "white" : "var(--muted)",
fontWeight: 600,
flexShrink: 0,
}}
>
Built-in
</span>
)}
{!toolType.is_builtin && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteToolType(toolType.id);
}}
style={{
background: "none",
border: "none",
color: selectedToolTypeId === toolType.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete tool type"
>
<Icon name="delete" size="sm" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteToolType(toolType.id);
}}
style={{
background: "none",
border: "none",
color: selectedToolTypeId === toolType.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete tool type"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>