diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go index ae1a6f3..3319c34 100644 --- a/cmd/creator-web/main.go +++ b/cmd/creator-web/main.go @@ -14,6 +14,7 @@ func main() { envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), &http.Client{Timeout: 10 * time.Second}, + envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), ) if err != nil { log.Fatal(err) diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 474ff0d..0bf5abd 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -9,6 +9,12 @@ import ( "net/http" "net/url" "strings" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/situation" + "git.cin.su/m/Assistant/platform/ids" ) const ( @@ -23,6 +29,7 @@ type gateway struct { core *url.URL continuity *url.URL commit *url.URL + cognitive *url.URL client *http.Client static http.Handler } @@ -35,7 +42,7 @@ const ( targetCommit ) -func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (http.Handler, error) { +func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, cognitiveURLs ...string) (http.Handler, error) { core, err := parseRuntimeURL(coreURL, "Core") if err != nil { return nil, err @@ -48,6 +55,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) ( if err != nil { return nil, err } + cognitiveURL := "http://cognitive-worker:8083" + if len(cognitiveURLs) > 0 { + cognitiveURL = cognitiveURLs[0] + } + cognitiveRuntime, err := parseRuntimeURL(cognitiveURL, "Cognitive") + if err != nil { + return nil, err + } assets, err := fs.Sub(staticFiles, "static") if err != nil { return nil, fmt.Errorf("load embedded console: %w", err) @@ -55,7 +70,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) ( if client == nil { client = http.DefaultClient } - return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, client: client, static: http.FileServer(http.FS(assets))}), nil + return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil } func parseRuntimeURL(rawURL, name string) (*url.URL, error) { @@ -73,6 +88,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if strings.HasPrefix(r.URL.Path, "/api/") { + if r.Method == http.MethodPost && r.URL.Path == "/api/advisory" { + g.handleAdvisory(w, r) + return + } g.forwardAPI(w, r) return } @@ -86,6 +105,123 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.static.ServeHTTP(w, r) } +type browserAdvisoryRequest struct { + Message string `json:"message"` + SessionID string `json:"session_id"` + WorkspaceID string `json:"workspace_id"` +} + +func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { + var browserRequest browserAdvisoryRequest + if err := decodeStrictJSON(r.Body, maxRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) { + http.Error(w, "invalid advisory request", http.StatusBadRequest) + return + } + authorization := r.Header.Get("Authorization") + if authorization == "" { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + var session identity.CreatorSession + status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session) + if err != nil { + http.Error(w, "creator session unavailable", http.StatusBadGateway) + return + } + if status != http.StatusOK { + http.Error(w, "creator session denied", status) + return + } + query := url.Values{"session_id": {browserRequest.SessionID}, "workspace_id": {browserRequest.WorkspaceID}}.Encode() + var focus situation.FocusBinding + status, err = g.getTrustedJSON(r, g.core, "/v1/creator/focus", query, authorization, &focus) + if err != nil { + http.Error(w, "creator focus unavailable", http.StatusBadGateway) + return + } + if status != http.StatusOK { + http.Error(w, "creator focus unavailable", status) + return + } + if session.ActorID == "" || session.PrincipalID == "" || focus.ActorID != session.ActorID || focus.ScopeID == "" { + http.Error(w, "creator focus denied", http.StatusForbidden) + return + } + requestID, err := ids.New() + if err != nil { + http.Error(w, "advisory unavailable", http.StatusServiceUnavailable) + return + } + trusted := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: focus.ScopeID, Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 16 * 1024, ProviderBindingID: capability.CapabilityBindingID(ids.ID("deterministic-fake")), ProviderBindingRevision: 1} + payload, err := json.Marshal(trusted) + if err != nil { + http.Error(w, "advisory unavailable", http.StatusServiceUnavailable) + return + } + target := *g.cognitive + target.Path = "/v1/advisories/deterministic" + upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload))) + if err != nil { + http.Error(w, "advisory unavailable", http.StatusBadGateway) + return + } + upstreamRequest.Header.Set("Content-Type", "application/json") + response, err := g.client.Do(upstreamRequest) + if err != nil { + http.Error(w, "advisory unavailable", http.StatusBadGateway) + return + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + http.Error(w, "advisory unavailable", http.StatusBadGateway) + return + } + var advisory cognitive.AdvisoryResponse + if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || advisory.RequestID != trusted.RequestID || advisory.ProviderBindingID != trusted.ProviderBindingID || advisory.ProviderBindingRevision != 1 || advisory.FinishReason != cognitive.FinishProviderUnavailable || advisory.AdvisoryText != "" || len(advisory.ReferencedExcerptIDs) != 0 { + http.Error(w, "invalid advisory response", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(advisory) +} + +func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQuery, authorization string, destination any) (int, error) { + target := *upstream + target.Path = path + target.RawQuery = rawQuery + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + return 0, err + } + request.Header.Set("Authorization", authorization) + response, err := g.client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes)) + return response.StatusCode, nil + } + if err := decodeStrictJSON(response.Body, maxResponseBytes, destination); err != nil { + return response.StatusCode, err + } + return response.StatusCode, nil +} + +func decodeStrictJSON(reader io.Reader, limit int64, destination any) error { + decoder := json.NewDecoder(io.LimitReader(reader, limit+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("trailing JSON") + } + return nil +} + func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) { routeTarget, upstreamPath, allowed := creatorRoute(r.Method, r.URL.Path) if !allowed { diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 8c78dd5..160e912 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -1,6 +1,8 @@ package main import ( + "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -222,6 +224,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") || !strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") || !strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") || + !strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") || !strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") || strings.Contains(response.Body.String(), "bootstrap-token") { t.Fatalf("console response = %d", response.Code) @@ -230,3 +233,80 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { t.Fatal("content security policy is missing") } } + +func TestAdvisoryRequiresSessionBeforeUpstreamCalls(t *testing.T) { + called := false + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer upstream.Close() + handler, err := newHandler(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), upstream.URL) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home"}`)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized || called { + t.Fatalf("status=%d upstream_called=%t", response.Code, called) + } +} + +func TestAdvisoryAssemblesTrustedIdentityAndScope(t *testing.T) { + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer bounded-session" { + t.Fatal("missing bounded session") + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/creator/sessions/current": + _, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`) + case "/v1/creator/focus": + if r.URL.Query().Get("session_id") != "focus-session" || r.URL.Query().Get("workspace_id") != "home" { + t.Fatalf("unexpected focus query: %s", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{"ID":"focus-1","ActorID":"creator-actor","SessionID":"focus-session","Workspace":"home","ProjectID":"domovoy","GoalID":"goal-1","ScopeID":"trusted-scope","Active":true}`) + default: + t.Fatalf("unexpected core path %s", r.URL.Path) + } + })) + defer core.Close() + cognitive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + text := string(body) + if !strings.Contains(text, `"ActorID":"creator-actor"`) || !strings.Contains(text, `"PrincipalID":"creator-principal"`) || !strings.Contains(text, `"ScopeID":"trusted-scope"`) || !strings.Contains(text, `"Message":"help"`) || strings.Contains(text, "attacker") { + t.Fatalf("untrusted cognitive request: %s", text) + } + var request map[string]any + _ = json.Unmarshal(body, &request) + _, _ = fmt.Fprintf(w, `{"Version":"creator-advisory-v1","RequestID":%q,"ProviderBindingID":"deterministic-fake","ProviderBindingRevision":1,"AdvisoryText":"","ReferencedExcerptIDs":null,"FinishReason":"PROVIDER_UNAVAILABLE","Usage":null}`, request["RequestID"]) + })) + defer cognitive.Close() + handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitive.URL) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home"}`)) + request.Header.Set("Authorization", "Bearer bounded-session") + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "PROVIDER_UNAVAILABLE") { + t.Fatalf("response=%d %s", response.Code, response.Body.String()) + } +} + +func TestAdvisoryRejectsBrowserAuthorityFieldsBeforeCognitiveCall(t *testing.T) { + called := false + cognitive := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer cognitive.Close() + handler, err := newHandler("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, cognitive.URL) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","ActorID":"attacker"}`)) + request.Header.Set("Authorization", "Bearer bounded-session") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || called { + t.Fatalf("status=%d cognitive_called=%t", response.Code, called) + } +} diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 508b63c..9b0c632 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -94,6 +94,26 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .signal-strip strong[data-state="error"] { color: var(--bad); } .signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; } +.advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); } +.advisor-intro { align-self: center; } +.advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; } +.advisor-intro h2 { margin: 0; font: 400 clamp(25px, 3vw, 36px)/1.05 var(--serif); } +.advisor-intro span { display: block; max-width: 44ch; margin-top: 12px; color: var(--muted); font-size: 11px; line-height: 1.55; } +.advisor-form { display: grid; gap: 9px; } +.advisor-form > label { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +.advisor-form textarea { width: 100%; min-height: 92px; padding: 13px 14px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: rgba(7,12,10,.82); font: 12px/1.55 var(--sans); transition: border-color .18s, box-shadow .18s; } +.advisor-form textarea:focus { outline: 0; border-color: var(--signal); box-shadow: 0 0 0 2px rgba(201,168,95,.12); } +.advisor-controls { display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.advisor-controls > span { color: var(--muted); font: 9px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; } +.advisor-controls i { width: 6px; height: 6px; margin-right: 7px; display: inline-block; border-radius: 50%; background: var(--good); box-shadow: 0 0 10px rgba(120,198,163,.7); } +.advisor-controls .primary-button { width: auto; min-width: 180px; margin: 0; } +.advisor-result { grid-column: 1 / -1; padding-top: 18px; display: grid; grid-template-columns: 150px minmax(0,1fr); gap: 18px; border-top: 1px solid var(--line); } +.advisor-result[hidden] { display: none; } +.advisor-result span { display: block; margin-bottom: 7px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .12em; } +.advisor-result strong { color: var(--warn); font: 600 10px/1.3 var(--mono); } +.advisor-result strong[data-state="COMPLETED"] { color: var(--good); } +.advisor-result p { margin: 0; color: var(--ink); font-size: 13px; line-height: 1.65; white-space: pre-wrap; } + .register-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16, 23, 20, .82); } .register-panel > header { padding: 17px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); } .register-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; } @@ -228,6 +248,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .context-rail { padding-inline: 20px; } .workspace { padding: 32px 24px; } .data-grid { grid-template-columns: 1fr; } + .advisor-panel { grid-template-columns: 1fr; } .capability-card { grid-column: auto; } .prospective-card { grid-column: auto; } } @@ -246,6 +267,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .last-update { text-align: left; } .signal-strip { grid-template-columns: 1fr; } .signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); } + .advisor-panel { padding: 20px 16px; gap: 20px; } + .advisor-controls { align-items: stretch; flex-direction: column; } + .advisor-controls .primary-button { width: 100%; } + .advisor-result { grid-template-columns: 1fr; gap: 10px; } .register-panel > header { align-items: start; } .activity-panel > header { align-items: flex-start; } .activity-tools { align-items: flex-end; flex-direction: column-reverse; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 08f67b2..f49b126 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -3,6 +3,7 @@ const loginView = byId("login-view"); const consoleView = byId("console-view"); const loginForm = byId("login-form"); const contextForm = byId("context-form"); +const advisoryForm = byId("advisory-form"); const loginError = byId("login-error"); const toast = byId("toast"); const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"]; @@ -96,6 +97,8 @@ function endSession(message = "Сессия завершена.") { pendingCommand = null; pendingNotification = null; lastAuthorizedActionID = ""; + advisoryForm.reset(); + byId("advisory-result").hidden = true; notify(message); } @@ -142,6 +145,40 @@ byId("refresh-all").addEventListener("click", loadDashboard); byId("refresh-actions").addEventListener("click", loadActionHistory); byId("logout").addEventListener("click", () => endSession()); +advisoryForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const context = saveContext(); + const message = byId("advisory-message").value.trim(); + const button = byId("advisory-submit"); + if (!context["session-id"] || !context["workspace-id"]) { + notify("Сначала укажите сессию и рабочую область контекста."); + return; + } + if (!message) return; + button.disabled = true; + button.firstElementChild.textContent = "Проверяю контекст…"; + byId("advisory-result").hidden = true; + try { + const result = await request("/api/advisory", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({message, session_id: context["session-id"], workspace_id: context["workspace-id"]}) + }); + const state = result.FinishReason || "INVALID_PROVIDER_RESPONSE"; + setText("advisory-state", state); + byId("advisory-state").dataset.state = state; + setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? "Когнитивный провайдер пока не подключён. Контекст проверен, наружу ничего не отправлено." : result.AdvisoryText, "Ответ не получен."); + byId("advisory-result").hidden = false; + } catch (error) { + setText("advisory-state", "ОШИБКА"); + setText("advisory-text", error.message); + byId("advisory-result").hidden = false; + } finally { + button.disabled = false; + button.firstElementChild.textContent = "Получить совет"; + } +}); + async function loadDashboard() { const context = saveContext(); const sessionID = context["session-id"]; diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index b97d3cd..5e76c22 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -92,6 +92,26 @@
COGNITIVE / ADVISORY V1
+Continuity / ближайшие