Changeset 0.26.0 (#165)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
196
server/pages/templates/workflow-landing.html
Normal file
196
server/pages/templates/workflow-landing.html
Normal 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}}
|
||||
Reference in New Issue
Block a user