feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user