feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+87
View File
@@ -0,0 +1,87 @@
package dify
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Only the backend has the bridge credential. Dify receives a short-lived step
// token, and records counts and IDs rather than elder speech or image bytes.
type Bridge struct {
BaseURL, Secret string
HTTP *http.Client
}
type Ticket struct {
ID string `json:"context_id"`
Token string `json:"step_token"`
}
func NewBridge(base, secret string) *Bridge {
return &Bridge{strings.TrimRight(base, "/"), secret, &http.Client{Timeout: 30 * time.Second}}
}
func (b *Bridge) request(ctx context.Context, method, path string, payload any, out any) error {
var body io.Reader
if payload != nil {
raw, _ := json.Marshal(payload)
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, b.BaseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+b.Secret)
req.Header.Set("Content-Type", "application/json")
resp, err := b.HTTP.Do(req)
if err != nil {
return ErrUnavailable
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return ErrUnavailable
}
if out != nil {
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
}
return nil
}
func (b *Bridge) Register(ctx context.Context, kind string, input any) (Ticket, error) {
var t Ticket
err := b.request(ctx, "POST", "/contexts", map[string]any{"kind": kind, "input": input}, &t)
return t, err
}
func (b *Bridge) Result(ctx context.Context, t Ticket, out any) error {
return b.request(ctx, "GET", "/contexts/"+t.ID, nil, out)
}
func (b *Bridge) Forget(t Ticket) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = b.request(ctx, "DELETE", "/contexts/"+t.ID, nil, nil)
}
func (b *Bridge) Image(ctx context.Context, t Ticket, index int) ([]byte, error) {
path := b.BaseURL + "/contexts/" + t.ID + "/images/" + strconv.Itoa(index)
req, _ := http.NewRequestWithContext(ctx, "GET", path, nil)
req.Header.Set("Authorization", "Bearer "+b.Secret)
resp, err := b.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, nil
}
if resp.StatusCode != 200 {
return nil, ErrUnavailable
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, (10<<20)+1))
if err != nil || len(raw) > 10<<20 || http.DetectContentType(raw) != "image/jpeg" {
return nil, errors.New("invalid workflow illustration")
}
return raw, nil
}
+134
View File
@@ -0,0 +1,134 @@
package dify
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var ErrUnavailable = errors.New("DIFY_WORKFLOW_UNAVAILABLE")
var ErrFailed = errors.New("DIFY_WORKFLOW_FAILED")
type Client struct {
BaseURL, APIKey string
HTTP *http.Client
}
type Event struct {
Event string `json:"event"`
WorkflowRunID string `json:"workflow_run_id"`
TaskID string `json:"task_id"`
Data struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Status string `json:"status"`
Error string `json:"error"`
Outputs map[string]json.RawMessage `json:"outputs"`
} `json:"data"`
}
type Run struct {
ID string
Outputs map[string]json.RawMessage
}
func NewClient(base, key string) (*Client, error) {
u, err := url.Parse(base)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || strings.TrimSpace(key) == "" {
return nil, errors.New("invalid Dify configuration")
}
return &Client{BaseURL: strings.TrimRight(base, "/"), APIKey: key, HTTP: &http.Client{Timeout: 13 * time.Minute}}, nil
}
// Streaming avoids the reverse proxy timeout for a book with many pictures,
// and gives the caller a chance to publish text before illustration completes.
func (c *Client) Run(ctx context.Context, inputs map[string]any, onNode func(Event) error) (Run, error) {
raw, _ := json.Marshal(map[string]any{"inputs": inputs, "response_mode": "streaming", "user": "story-platform"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/workflows/run", bytes.NewReader(raw))
if err != nil {
return Run{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return Run{}, ErrUnavailable
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Run{}, ErrUnavailable
}
scanner := bufio.NewScanner(io.LimitReader(resp.Body, 16<<20))
scanner.Buffer(make([]byte, 4096), 2<<20)
var run Run
finished := false
var taskID string
defer func() {
if !finished && taskID != "" {
c.stop(taskID)
}
}()
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
break
}
var event Event
if json.Unmarshal([]byte(payload), &event) != nil {
return run, ErrFailed
}
if event.WorkflowRunID != "" {
run.ID = event.WorkflowRunID
}
if event.TaskID != "" {
taskID = event.TaskID
}
if event.Event == "node_finished" && event.Data.Status == "succeeded" && onNode != nil {
if err = onNode(event); err != nil {
return run, err
}
}
if event.Event == "workflow_finished" {
finished = true
if event.Data.Status != "succeeded" {
return run, ErrFailed
}
run.Outputs = event.Data.Outputs
if run.ID == "" {
run.ID = event.Data.ID
}
}
if event.Event == "error" {
return run, ErrFailed
}
}
if scanner.Err() != nil || !finished {
return run, ErrUnavailable
}
return run, nil
}
func (c *Client) stop(taskID string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body := strings.NewReader(`{"user":"story-platform"}`)
req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/workflows/tasks/"+taskID+"/stop", body)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err == nil {
resp.Body.Close()
}
}
+69
View File
@@ -0,0 +1,69 @@
package dify
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestStreamingPublishesAtNodeAndStopsInterruptedRun(t *testing.T) {
for _, interrupt := range []bool{false, true} {
t.Run(fmt.Sprint(interrupt), func(t *testing.T) {
var stops atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer private-key" {
t.Error("missing authorization")
}
if r.URL.Path == "/workflows/tasks/task-1/stop" {
stops.Add(1)
return
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintln(w, `data: {"event":"workflow_started","task_id":"task-1","workflow_run_id":"run-1"}`)
fmt.Fprintln(w, `data: {"event":"node_finished","workflow_run_id":"run-1","data":{"node_id":"validate-book","status":"succeeded"}}`)
fmt.Fprintln(w, `data: {"event":"workflow_finished","data":{"id":"run-1","status":"succeeded","outputs":{"chapter_count":7}}}`)
}))
defer srv.Close()
c, _ := NewClient(srv.URL, "private-key")
published := false
sentinel := errors.New("authorization revoked")
run, err := c.Run(context.Background(), map[string]any{"context_id": "opaque"}, func(e Event) error {
published = e.Data.NodeID == "validate-book"
if interrupt {
return sentinel
}
return nil
})
if !published || run.ID != "run-1" {
t.Fatalf("missing early publication or run ID: %+v", run)
}
if interrupt {
if !errors.Is(err, sentinel) || stops.Load() != 1 {
t.Fatalf("err=%v stops=%d", err, stops.Load())
}
} else if err != nil || stops.Load() != 0 || string(run.Outputs["chapter_count"]) != "7" {
t.Fatalf("run=%+v err=%v", run, err)
}
})
}
}
func TestTruncatedStreamCannotReportSuccess(t *testing.T) {
var stops atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/workflows/tasks/task-2/stop" {
stops.Add(1)
return
}
fmt.Fprintln(w, `data: {"event":"workflow_started","task_id":"task-2","workflow_run_id":"run-2"}`)
}))
defer srv.Close()
c, _ := NewClient(srv.URL, "private-key")
if _, err := c.Run(context.Background(), nil, nil); !errors.Is(err, ErrUnavailable) || stops.Load() != 1 {
t.Fatalf("err=%v stops=%d", err, stops.Load())
}
}