返回 DeepSeek-Reasonix
extension_ui.go
根目录 / internal / control / extension_ui.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/event"
10 "reasonix/internal/extension/uihub"
11 )
12
13 // Extension UI hub wiring (Extension Protocol v1, stage 8a). The hub is the
14 // host side of the extension structured-UI surface: sidecar publications
15 // arrive as events through EmitExtensionEvent, blocking prompts ride the
16 // ordinary Ask channel, and handshake-declared actions are exposed to
17 // frontends through the Capabilities port as /<plugin>:<action> names. Every
18 // wiring point is nil-safe: with no hub installed (no v1 runtime packages)
19 // the controller behaves exactly as before.
20
21 // ExtensionActionView is one handshake-declared extension UI action for
22 // frontend enumeration. Slash is the public invocation name,
23 // "/<plugin>:<action>".
24 type ExtensionActionView struct {
25 PluginID string
26 ActionID string
27 Label string
28 Slash string
29 }
30
31 // SetExtensionUI installs the extension UI hub after construction. Boot uses
32 // it because sidecars — and therefore the hub — only exist after snapshot
33 // assembly, which runs after New. The first non-nil install wins; a
34 // controller generation never swaps hubs. Nil is a no-op.
35 func (c *Controller) SetExtensionUI(h *uihub.Hub) {
36 if h == nil {
37 return
38 }
39 c.mu.Lock()
40 defer c.mu.Unlock()
41 if c.extensionUI != nil {
42 return
43 }
44 c.extensionUI = h
45 }
46
47 // extensionUIHub returns the installed hub, or nil.
48 func (c *Controller) extensionUIHub() *uihub.Hub {
49 c.mu.Lock()
50 defer c.mu.Unlock()
51 return c.extensionUI
52 }
53
54 // EmitExtensionEvent emits one extension-sourced event to the controller's
55 // sink. The hub calls it for host/ui/publish traffic; reading the sink under
56 // lock keeps the emission race-free against SetExtensions installing the
57 // frontend-event strategy sink during boot.
58 func (c *Controller) EmitExtensionEvent(ev event.Event) {
59 c.mu.Lock()
60 sink := c.sink
61 c.mu.Unlock()
62 if sink == nil {
63 return
64 }
65 sink.Emit(ev)
66 }
67
68 // ExtensionActions enumerates every registered extension UI action (the
69 // Capabilities port addition consumed by the stage-8b slash dispatch). Nil
70 // hub → empty.
71 func (c *Controller) ExtensionActions() []ExtensionActionView {
72 h := c.extensionUIHub()
73 if h == nil {
74 return nil
75 }
76 registered := h.Actions()
77 out := make([]ExtensionActionView, 0, len(registered))
78 for _, action := range registered {
79 out = append(out, ExtensionActionView{
80 PluginID: action.PluginID,
81 ActionID: action.ActionID,
82 Label: action.Label,
83 Slash: action.Slash,
84 })
85 }
86 return out
87 }
88
89 // InvokeExtensionAction invokes one registered action by its public
90 // "/<plugin>:<action>" name (or bare "<plugin>:<action>") and returns the
91 // extension's (already redacted) result message.
92 func (c *Controller) InvokeExtensionAction(ctx context.Context, name string, args map[string]string) (string, error) {
93 h := c.extensionUIHub()
94 if h == nil {
95 return "", errors.New("no extension UI hub is installed (no extension runtimes started)")
96 }
97 pluginID, actionID, ok := uihub.ParseSlashName(name)
98 if !ok {
99 return "", fmt.Errorf("invalid extension action name %q: want /<plugin>:<action>", name)
100 }
101 result, err := h.InvokeAction(ctx, pluginID, actionID, h.SessionID(), args)
102 if err != nil {
103 return "", err
104 }
105 if !result.Accepted {
106 if result.Message != "" {
107 return "", errors.New(result.Message)
108 }
109 return "", fmt.Errorf("extension %s did not accept action %s", pluginID, actionID)
110 }
111 return result.Message, nil
112 }
113
114 // SubmitExtensionForm delivers one extension form surface's values back to
115 // the owning sidecar (stage-8b frontends call it when a form is submitted).
116 func (c *Controller) SubmitExtensionForm(ctx context.Context, pluginID, surfaceID string, values map[string]any) error {
117 h := c.extensionUIHub()
118 if h == nil {
119 return errors.New("no extension UI hub is installed (no extension runtimes started)")
120 }
121 result, err := h.Submit(ctx, pluginID, surfaceID, h.SessionID(), values)
122 if err != nil {
123 return err
124 }
125 if !result.Accepted {
126 return fmt.Errorf("extension %s did not accept the submission for surface %s", pluginID, surfaceID)
127 }
128 return nil
129 }
130
131 // ParseExtensionActionArgs maps the trailing fields of a
132 // "/<plugin>:<action> args…" invocation onto the action's string map:
133 // key=value fields become named entries, bare fields land in positional
134 // arg1..argN keys. Both stage-8b frontends (TUI slash dispatch, ACP slash
135 // resolution) share this one parsing convention.
136 func ParseExtensionActionArgs(fields []string) map[string]string {
137 if len(fields) == 0 {
138 return nil
139 }
140 args := map[string]string{}
141 positional := 0
142 for _, field := range fields {
143 if k, v, ok := strings.Cut(field, "="); ok && k != "" {
144 args[k] = v
145 continue
146 }
147 positional++
148 args[fmt.Sprintf("arg%d", positional)] = field
149 }
150 return args
151 }
152
152 lines GO