Files
multica/server/internal/handler/config.go
T
09a2410e88 refactor(agents): rename starter_prompts to conversation_starters (MUL-6709) (#7592)
* refactor(agents): rename starter_prompts to conversation_starters

The UI, docs and all four locales call this feature "Conversation starters"
(#7573), but every identifier behind it still said `starter_prompts` — the DB
column, the wire field, the `/api/config` capability flag, the TS types, the
i18n keys, and the `?focus=` token that a user can read in their own address
bar. The old name is also the one #7573 rejected on the way in: it promises a
prompt, while selecting a starter only prefills the composer.

Rename it everywhere so the identifier matches the product.

This is a breaking rename with no compatibility shim, and it is free exactly
once: the column arrived in migration 404 (#7261) after the last release tag,
so nothing has shipped. No deployed client reads the old name, no stored data
uses it, and `agent_conversation_starters_supported` has no version skew to
straddle. After this release, the same change would cost a wire alias, a
dual-written capability flag, and older Desktop/Mobile builds silently hiding
the editor.

Migration 404 stays as-shipped; 432 renames the column and its CHECK
constraint, so an already-migrated dev or staging database converges with a
fresh one.

Co-authored-by: multica-agent <[email protected]>

* docs(skills): name the conversation-starters surface for agents

`multica-creating-agents` mentioned `conversation_starters` only as a wire
field in two tables. It never said what the feature is called in the product,
where a human configures it, or what selecting one does — so an agent reading
this skill would tell a user about "conversation starters" as a JSON field and
leave them with nowhere to go.

Add the product name (both languages), the Instructions-tab deep link, and the
behaviour that makes the name accurate: selecting one only fills the composer.
Also record that an agent with none configured still shows three built-in
defaults, so "the Chat shows suggestions" is not evidence this agent has any.

Document one gap the field table implied away: the accepted-body list is not a
CLI surface. `conversation_starters` has no `agent create` / `agent update`
flag — only `agent copy` carries an existing value forward — so an agent
following the old text would build a create command that silently drops it.

Co-authored-by: multica-agent <[email protected]>

* fix(onboarding): keep the released starter_prompt field on the pre-v3 shim

The rename in ec44cc0ef swept up `starter_prompt` (singular) on
`bootstrapOnboardingRuntimeRequest`. That is a different contract from the
per-agent `starter_prompts` this PR renames: it is a field on the endpoint
marked "DEPRECATED, kept for desktop < v3", and unlike the per-agent one it
HAS shipped — it is in v0.4.34, released since fbd965e5bf.

Renaming it breaks the clients it exists for, and breaks them silently: the
handler decodes with a plain json.Decoder, so an old Desktop build's
`starter_prompt` would be dropped as an unknown field, the request would still
return 200, and the onboarding issue would quietly fall back to the default
description instead of the text the user typed. The test change hid it by
moving the fixture to the new name, removing the only coverage of the old one.

Revert both files. The two features share a word root and nothing else — the
free-rename argument for the per-agent field does not extend to a contract
that is already in the wild.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 19:01:01 +08:00

216 lines
9.3 KiB
Go

package handler
import (
"net/http"
"net/url"
"os"
"strings"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/featureflags"
)
type AppConfig struct {
CdnDomain string `json:"cdn_domain"`
// CdnSigned tells clients that the CDN domain above serves PRIVATE
// content through time-bounded signed URLs (CloudFront signing is
// enabled). When true, a raw storage URL on the CDN domain is NOT
// publicly fetchable — renderers must not pick it as a native
// <img>/<video> source and should fall back to the per-attachment
// API endpoint or a freshly signed download_url instead (MUL-3254).
// Omitted when false so older clients see the previous shape.
CdnSigned bool `json:"cdn_signed,omitempty"`
// Public auth config consumed by the web app at runtime so self-hosted
// deployments do not need to rebuild the frontend image when operators
// toggle signup or wire Google OAuth.
AllowSignup bool `json:"allow_signup"`
GoogleClientID string `json:"google_client_id,omitempty"`
// WorkspaceCreationDisabled mirrors the server-side
// DISABLE_WORKSPACE_CREATION env var so the UI can hide every
// "Create workspace" affordance on self-hosted instances. Omitted
// from the JSON when false to keep responses identical to the
// previous shape for the common managed-cloud case (#3433).
WorkspaceCreationDisabled bool `json:"workspace_creation_disabled,omitempty"`
// Public daemon setup config consumed by the web app at runtime so
// self-hosted instances can show `multica setup self-host` commands
// with the operator's own domains instead of Multica Cloud defaults.
DaemonServerURL string `json:"daemon_server_url,omitempty"`
DaemonAppURL string `json:"daemon_app_url,omitempty"`
// VCSIntegrationAvailable mirrors the MULTICA_VCS_INTEGRATION_ENABLED
// deployment switch so the Settings UI can hide the whole self-hosted Git
// provider section on deployments where it is off (the managed cloud),
// instead of rendering it and surfacing an operator-only "missing
// MULTICA_VCS_SECRET_KEY" hint a cloud user cannot resolve. Omitted when
// false so the managed-cloud response keeps its previous shape; the UI
// defaults absent to false (hidden).
VCSIntegrationAvailable bool `json:"vcs_integration_available,omitempty"`
// PostHog public config for the frontend. The key is the same Project
// API Key the backend uses; returning it here (instead of baking it
// into the frontend bundle via NEXT_PUBLIC_*) means self-hosted
// instances — whose server returns an empty key — automatically
// disable frontend event shipping too.
PosthogKey string `json:"posthog_key"`
PosthogHost string `json:"posthog_host"`
AnalyticsEnvironment string `json:"analytics_environment"`
// FeatureFlags exposes only frontend-safe boolean decisions. Do not dump
// raw rules here: /api/config is public and may be called anonymously.
FeatureFlags map[string]bool `json:"feature_flags,omitempty"`
// LocalWorktreeSupported tells clients this server understands
// local_directory `execution_mode` and enforces the worktree capability
// gate when a resource is saved.
//
// Load-bearing for CLIENTS, not for this server. Releases before v0.4.25
// unmarshalled the ref into a struct without the field and re-marshalled
// it, so `execution_mode: "worktree"` was silently DROPPED and answered
// 201 — the resource then ran in_place, editing the working copy the user
// asked to isolate, with no gate anywhere to catch it. A new client cannot
// tell that from success, so it has to ask first, and absent has to read as
// "cannot honour it": every release that drops the field also omits this
// one. Releases between that fix and this signal do gate the save but say
// nothing, so they are treated the same way — the client cannot distinguish
// them, and only one of the two guesses is safe.
LocalWorktreeSupported bool `json:"local_worktree_supported"`
// AgentConversationStartersSupported tells independently deployed clients
// that agent create/update persists conversation_starters. Older handlers
// ignored the unknown JSON field and still returned success, so clients
// must fail closed when this declaration is absent.
AgentConversationStartersSupported bool `json:"agent_conversation_starters_supported"`
// ServerVersion is the running API build version, so self-hosted
// operators can confirm what's deployed and include it in bug reports.
// Only emitted on self-hosted deployments — omitted on the managed cloud,
// which is continuously deployed so its users can't act on the version —
// and empty for dev builds that aren't stamped via -X main.version.
ServerVersion string `json:"server_version,omitempty"`
}
// GetConfig is mounted on the public (unauthenticated) route group because
// the web app calls it before login to decide whether to render the Google
// sign-in button and signup UI. Only add fields here that are safe to expose
// to anonymous callers — never user- or tenant-scoped data.
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
config := AppConfig{
// A property of this build, not of the deployment: if this code is
// running, the save gate is running with it.
LocalWorktreeSupported: true,
AgentConversationStartersSupported: true,
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
WorkspaceCreationDisabled: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
}
if h.Storage != nil {
config.CdnDomain = h.Storage.CdnDomain()
}
config.CdnSigned = h.CFSigner != nil
config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv()
config.VCSIntegrationAvailable = h.cfg.VCSIntegrationEnabled
config.FeatureFlags = featureflags.EvaluateFrontendPublicFlags(r.Context(), h.FeatureFlags)
// Only surface the build version on self-hosted deployments. The managed
// cloud is continuously deployed and its users can't choose the build, so
// the Help popover's version row would just be noise there (MUL-4108).
if !isOfficialCloudDeployment() {
config.ServerVersion = h.cfg.ServerVersion
}
// Re-read from env on every request so operators can rotate keys via
// secret refresh without a server restart.
if v := os.Getenv("ANALYTICS_DISABLED"); v != "true" && v != "1" {
config.PosthogKey = os.Getenv("POSTHOG_API_KEY")
config.PosthogHost = os.Getenv("POSTHOG_HOST")
config.AnalyticsEnvironment = analytics.EnvironmentFromEnv()
if config.PosthogHost == "" && config.PosthogKey != "" {
config.PosthogHost = "https://us.i.posthog.com"
}
}
writeJSON(w, http.StatusOK, config)
}
func daemonSetupURLsFromEnv() (string, string) {
serverURL := normalizePublicURL(os.Getenv("MULTICA_DAEMON_SERVER_URL"))
if serverURL == "" {
serverURL = normalizePublicURL(os.Getenv("MULTICA_PUBLIC_URL"))
}
appURL := resolveFrontendAppURL()
if appURL == "" {
return "", ""
}
if serverURL == "" {
serverURL = appURL
}
if isOfficialCloudDaemonConfig(appURL) {
return "", ""
}
return serverURL, appURL
}
// resolveFrontendAppURL returns the operator-configured frontend origin
// (MULTICA_APP_URL, falling back to FRONTEND_ORIGIN), normalized. Shared by
// the daemon-setup URLs and the managed-cloud detection so both read the same
// signal.
func resolveFrontendAppURL() string {
appURL := normalizePublicURL(os.Getenv("MULTICA_APP_URL"))
if appURL == "" {
appURL = normalizePublicURL(os.Getenv("FRONTEND_ORIGIN"))
}
return appURL
}
func normalizePublicURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
// isOfficialCloudDaemonConfig reports whether this deployment is the official
// Multica Cloud, identified by its frontend host alone (multica.ai). The
// daemon setup for the managed cloud is always
// `multica setup` (which hardcodes api.multica.ai), so the per-deployment URLs
// must be omitted from /api/config even when MULTICA_PUBLIC_URL is unset or
// misconfigured. Previously this also required serverURL==api.multica.ai, so a
// cloud deployment that forgot MULTICA_PUBLIC_URL fell through and emitted a
// `setup self-host --server-url https://multica.ai` command — pointing the
// daemon's backend at the frontend (no /health, no WebSocket proxy).
func isOfficialCloudDaemonConfig(appURL string) bool {
return urlHostEquals(appURL, "multica.ai")
}
// isOfficialCloudDeployment reports whether this server is the official Multica
// Cloud, reusing the same frontend-host signal as the daemon setup (multica.ai).
// Managed-cloud-only behavior — such as suppressing the Help popover's
// server-version row, which only matters to self-hosted operators — is gated on
// this.
func isOfficialCloudDeployment() bool {
return isOfficialCloudDaemonConfig(resolveFrontendAppURL())
}
func urlHostEquals(raw, want string) bool {
host := canonicalURLHost(raw)
if host == "" {
return false
}
want = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(want)), ".")
return host == want
}
func canonicalURLHost(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil {
return ""
}
host := u.Hostname()
if host == "" && !strings.Contains(raw, "://") {
u, err = url.Parse("https://" + raw)
if err != nil {
return ""
}
host = u.Hostname()
}
return strings.TrimSuffix(strings.ToLower(host), ".")
}