Changeset 0.26.0 (#165)

This commit is contained in:
2026-03-10 13:38:01 +00:00
parent dbc1a97343
commit 400f7dd176
48 changed files with 4923 additions and 208 deletions

View File

@@ -182,6 +182,11 @@ func (e *Engine) registerCoreSurfaces() {
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
{
ID: "workflow-landing", Route: "/w/:id/:slug",
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
Layout: "single", Source: "core",
},
}
}
@@ -377,7 +382,7 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
registerRoutes(base, s, e.disabledRedirect())
continue
}
handler := e.RenderSurface(s.ID)
handler := e.surfaceHandler(s)
registerRoutes(base, s, handler)
}
}
@@ -390,6 +395,9 @@ func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" {
return e.RenderWorkflow()
}
if s.ID == "workflow-landing" {
return e.RenderWorkflowLanding()
}
return e.RenderSurface(s.ID)
}
@@ -469,6 +477,24 @@ type WorkflowPageData struct {
SessionName string
}
// WorkflowLandingPageData is passed to workflow-landing.html.
type WorkflowLandingPageData struct {
WorkflowID string
Scope string
Slug string
Name string
Description string
Branding struct {
AccentColor string `json:"accent_color"`
LogoURL string `json:"logo_url"`
Tagline string `json:"tagline"`
}
PersonaName string
PersonaIcon string
StageCount int
ResumeURL string // non-empty if visitor has an active session
}
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
@@ -514,6 +540,80 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
}
}
// RenderWorkflowLanding renders the public landing page for a workflow.
// Visitors see the workflow name, description, persona info, and a Start button.
func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// Route: /w/:id/:slug — :id is scope (team-id or "global"),
// :slug is the workflow slug. Same :id param name as /w/:id
// (chat surface) to avoid Gin wildcard conflicts.
scope := c.Param("id")
slug := c.Param("slug")
// Resolve scope to team_id
var teamID *string
if scope != "global" {
teamID = &scope
}
if e.stores.Workflows == nil {
c.String(http.StatusNotFound, "Workflows not available")
return
}
wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
if err != nil || wf == nil {
c.String(http.StatusNotFound, "Workflow not found")
return
}
if !wf.IsActive {
c.String(http.StatusNotFound, "Workflow not available")
return
}
data := WorkflowLandingPageData{
WorkflowID: wf.ID,
Scope: scope,
Slug: slug,
Name: wf.Name,
Description: wf.Description,
}
// Parse branding
if len(wf.Branding) > 0 {
_ = json.Unmarshal(wf.Branding, &data.Branding)
}
// Load stages for count + first persona info
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
data.StageCount = len(stages)
if len(stages) > 0 && stages[0].PersonaID != nil {
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
data.PersonaName = p.Name
data.PersonaIcon = p.Icon
}
}
// Check for existing active session
sbSession, err := c.Cookie("sb_session")
if err == nil && sbSession != "" && e.stores.Sessions != nil {
sess, err := e.stores.Sessions.GetByToken(ctx, sbSession)
if err == nil && sess != nil {
data.ResumeURL = "/w/" + sess.ChannelID
}
}
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow-landing.html", PageData{
Surface: "workflow-landing",
InstanceName: instanceName,
Data: data,
})
}
}
// getUserContext extracts user info from gin context (set by auth middleware)
// and enriches it with display name, username, and email from the database.
func (e *Engine) getUserContext(c *gin.Context) *UserContext {

View File

@@ -12,6 +12,10 @@ import (
// Called once at startup. Does NOT overwrite the enabled flag — admin
// toggles survive restarts.
func (e *Engine) SeedSurfaces() {
if e.stores.Surfaces == nil {
log.Printf("[pages] Surface registry not available — skipping seed")
return
}
ctx := context.Background()
for _, s := range e.surfaces {
manifest := map[string]any{
@@ -38,6 +42,9 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
if surfaceID == "chat" || surfaceID == "admin" {
return true
}
if e.stores.Surfaces == nil {
return true // No registry = all surfaces enabled (backward compat)
}
ctx := context.Background()
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
if err != nil || sr == nil {
@@ -48,6 +55,14 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
func (e *Engine) EnabledSurfaceIDs() []string {
if e.stores.Surfaces == nil {
// No registry — return all core surface IDs
var all []string
for _, s := range e.surfaces {
all = append(all, s.ID)
}
return all
}
ctx := context.Background()
ids, err := e.stores.Surfaces.ListEnabled(ctx)
if err != nil {

View File

@@ -20,6 +20,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}

View File

@@ -28,6 +28,10 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/></svg>
AI
</a>
<a href="{{$base}}/admin/workflows" class="admin-cat-btn{{if eq $section "workflows"}} active{{end}}" data-cat="workflows">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
Workflows
</a>
<a href="{{$base}}/admin/health" class="admin-cat-btn{{if eq $section "health"}} active{{else if eq $section "routing"}} active{{else if eq $section "capabilities"}} active{{end}}" data-cat="routing">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Routing
@@ -212,6 +216,8 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/files.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -519,5 +519,8 @@ window.addEventListener('unhandledrejection', function(e) {
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
{{end}}

View File

@@ -0,0 +1,196 @@
{{define "workflow-landing.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Data.Name}} — {{.InstanceName}}</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--wf-accent: {{if .Data.Branding.AccentColor}}{{.Data.Branding.AccentColor}}{{else}}var(--accent){{end}};
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.wf-landing {
max-width: 480px;
width: 100%;
padding: 24px;
text-align: center;
}
.wf-logo {
width: 64px;
height: 64px;
border-radius: 16px;
background: var(--wf-accent);
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
}
.wf-logo img {
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: cover;
}
.wf-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.wf-description {
font-size: 15px;
color: var(--text-2);
line-height: 1.6;
margin-bottom: 32px;
}
.wf-tagline {
font-size: 13px;
color: var(--text-3);
margin-bottom: 24px;
font-style: italic;
}
.wf-persona {
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
margin-bottom: 32px;
font-size: 14px;
color: var(--text-2);
}
.wf-persona-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-raised);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.wf-start-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 40px;
background: var(--wf-accent);
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
font-family: var(--font);
cursor: pointer;
transition: opacity 0.15s;
}
.wf-start-btn:hover { opacity: 0.9; }
.wf-start-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wf-resume {
display: block;
margin-top: 16px;
font-size: 14px;
color: var(--wf-accent);
text-decoration: none;
}
.wf-resume:hover { text-decoration: underline; }
.wf-footer {
margin-top: 48px;
font-size: 12px;
color: var(--text-3);
}
@media (prefers-color-scheme: dark) {
body { background: var(--bg); color: var(--text); }
}
</style>
<script>
// Apply dark/light based on OS preference
(function() {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
})();
</script>
</head>
<body>
<div class="wf-landing">
{{if .Data.Branding.LogoURL}}
<div class="wf-logo">
<img src="{{.Data.Branding.LogoURL}}" alt="{{.Data.Name}}">
</div>
{{else}}
<div class="wf-logo">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}⚡{{end}}</div>
{{end}}
<h1 class="wf-title">{{.Data.Name}}</h1>
{{if .Data.Branding.Tagline}}
<p class="wf-tagline">{{.Data.Branding.Tagline}}</p>
{{end}}
{{if .Data.Description}}
<p class="wf-description">{{.Data.Description}}</p>
{{end}}
{{if .Data.PersonaName}}
<div class="wf-persona">
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
</div>
{{end}}
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
Start
</button>
{{if .Data.ResumeURL}}
<a class="wf-resume" href="{{.Data.ResumeURL}}">Resume previous session →</a>
{{end}}
<div class="wf-footer">
Powered by {{.InstanceName}}
</div>
</div>
<script>
async function startWorkflow() {
const btn = document.getElementById('startBtn');
btn.disabled = true;
btn.textContent = 'Starting…';
try {
const resp = await fetch('{{.BasePath}}/api/v1/workflow-entry/{{.Data.Scope}}/{{.Data.Slug}}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await resp.json();
if (resp.ok && data.redirect_to) {
window.location.href = '{{.BasePath}}' + data.redirect_to;
} else {
alert(data.error || 'Failed to start workflow');
btn.disabled = false;
btn.textContent = 'Start';
}
} catch (err) {
alert('Network error — please try again');
btn.disabled = false;
btn.textContent = 'Start';
}
}
</script>
</body>
</html>
{{end}}