diff --git a/adapters/postgres/interaction_repository.go b/adapters/postgres/interaction_repository.go new file mode 100644 index 0000000..cc1176a --- /dev/null +++ b/adapters/postgres/interaction_repository.go @@ -0,0 +1,66 @@ +package postgres + +import ( + "context" + "fmt" + + "git.cin.su/m/Assistant/contracts/interaction" + "github.com/jackc/pgx/v5/pgxpool" +) + +type InteractionRepository struct{ pool *pgxpool.Pool } + +func OpenInteraction(ctx context.Context, c ContinuityConfig) (*InteractionRepository, error) { + p, e := pgxpool.New(ctx, fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s", c.Host, c.Port, c.Database, c.User, c.Password)) + return &InteractionRepository{p}, e +} +func (r *InteractionRepository) Close() { r.pool.Close() } + +func (r *InteractionRepository) CreateConversation(ctx context.Context, c interaction.Conversation) error { + _, err := r.pool.Exec(ctx, `INSERT INTO interaction.conversations(conversation_id,actor_id,principal_id,channel,state,created_at_unix_millis,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(conversation_id) DO NOTHING`, c.ID, c.ActorID, c.PrincipalID, c.Channel, c.State, c.CreatedAtUnixMillis, c.UpdatedAtUnixMillis) + return err +} + +func (r *InteractionRepository) AppendUtterance(ctx context.Context, c interaction.Conversation, u interaction.HumanUtterance, intent interaction.OriginalHumanIntent, relations []interaction.InteractionRelation) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err = tx.Exec(ctx, `INSERT INTO interaction.conversations(conversation_id,actor_id,principal_id,channel,state,created_at_unix_millis,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(conversation_id) DO UPDATE SET updated_at_unix_millis=EXCLUDED.updated_at_unix_millis`, c.ID, c.ActorID, c.PrincipalID, c.Channel, c.State, c.CreatedAtUnixMillis, c.UpdatedAtUnixMillis); err != nil { + return err + } + if _, err = tx.Exec(ctx, `INSERT INTO interaction.utterances(utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, u.ID, u.ConversationID, u.ActorID, u.PrincipalID, u.Channel, u.ReceivedAtUnixMillis, u.CorrelationID, u.Content, u.ContentRef, u.SpeechAct, u.InterpretationState, u.CorrectsUtteranceID); err != nil { + return err + } + if _, err = tx.Exec(ctx, `INSERT INTO interaction.original_human_intents(intent_id,utterance_id,recorded_at_unix_millis,text) VALUES($1,$2,$3,$4)`, intent.ID, intent.UtteranceID, intent.RecordedAtUnixMillis, intent.Text); err != nil { + return err + } + for _, relation := range relations { + if _, err = tx.Exec(ctx, `INSERT INTO interaction.relations(relation_id,from_utterance_id,to_utterance_id,kind,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5)`, relation.ID, relation.FromUtteranceID, relation.ToUtteranceID, relation.Kind, relation.RecordedAtUnixMillis); err != nil { + return err + } + } + return tx.Commit(ctx) +} +func (r *InteractionRepository) Conversation(ctx context.Context, id interaction.ConversationID) (interaction.Conversation, error) { + var c interaction.Conversation + e := r.pool.QueryRow(ctx, `SELECT conversation_id,actor_id,principal_id,channel,state,created_at_unix_millis,updated_at_unix_millis FROM interaction.conversations WHERE conversation_id=$1`, id).Scan(&c.ID, &c.ActorID, &c.PrincipalID, &c.Channel, &c.State, &c.CreatedAtUnixMillis, &c.UpdatedAtUnixMillis) + return c, e +} +func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.ConversationID) ([]interaction.HumanUtterance, error) { + rows, e := r.pool.Query(ctx, `SELECT utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id FROM interaction.utterances WHERE conversation_id=$1 ORDER BY received_at_unix_millis,utterance_id`, id) + if e != nil { + return nil, e + } + defer rows.Close() + var out []interaction.HumanUtterance + for rows.Next() { + var u interaction.HumanUtterance + if e = rows.Scan(&u.ID, &u.ConversationID, &u.ActorID, &u.PrincipalID, &u.Channel, &u.ReceivedAtUnixMillis, &u.CorrelationID, &u.Content, &u.ContentRef, &u.SpeechAct, &u.InterpretationState, &u.CorrectsUtteranceID); e != nil { + return nil, e + } + out = append(out, u) + } + return out, rows.Err() +} diff --git a/architecture/adrs/ADR-0009-human-interaction-owner.md b/architecture/adrs/ADR-0009-human-interaction-owner.md new file mode 100644 index 0000000..eab3881 --- /dev/null +++ b/architecture/adrs/ADR-0009-human-interaction-owner.md @@ -0,0 +1,19 @@ +# ADR-0009 — Human Interaction canonical owner + +Status: Accepted +Date: 2026-08-17 +Accepted under: DEV-000061 rev.1A Creator decision + +## Decision + +`interaction` is the narrow canonical owner for `Conversation`, `HumanUtterance`, typed `SpeechAct`, immutable/versioned `OriginalHumanIntent`, interpretation revisions, correction relations, and interaction provenance. It is transport-neutral; `WEB_CHAT` is its first channel. + +Interaction stores actor/session/identity-assurance references but does not own authentication or identity truth. It does not own Claims, Evidence, Observations, Beliefs, Work state, Authority, Commit, provider selection, model state, generic memory, or general Artifacts. Assertions may be proposed to Epistemics but never become verified fact automatically. Directives remain non-executable proposals and must use existing Authority/Commit paths. + +Raw content is an immutable Interaction-owned bounded payload in V1; `ContentRef` permits a later Artifact migration without historical rewrite. Conversation only references Situation/Work context and cannot contain their state. Corrections append a new utterance/relation; historical messages are never rewritten. + +Questions may use only the provider-neutral Cognitive Gateway/Router. Cognitive output is advisory and cannot mutate canonical state. Interaction has no provider-specific import or credential. + +## Consequences + +A dedicated `interaction` schema/role and typed contracts are added. No cross-owner SQL join, FK, direct write, privilege escalation, provider activation, or automatic action is introduced. diff --git a/architecture/adrs/ADR-0010-credential-ref-secret-resolution.md b/architecture/adrs/ADR-0010-credential-ref-secret-resolution.md new file mode 100644 index 0000000..87c571a --- /dev/null +++ b/architecture/adrs/ADR-0010-credential-ref-secret-resolution.md @@ -0,0 +1,26 @@ +# ADR-0010 - CredentialRef Secret Resolution + +Status: Accepted +Date: 2026-08-17 +Accepted under: DEV-000061 rev.2 Creator scope decision + +## Decision + +Long-lived runtime credentials are represented in canonical configuration as typed `CredentialRef` metadata, never as plaintext secret values. A trusted runtime resolves a `CredentialRef` through a provider-neutral Secret Resolver into a secret value stored in a protected Secret Backend. + +The V1 backend is the existing protected local secret storage exposed to trusted runtimes as files under an explicitly configured root such as `/run/secrets`. This is bootstrap delivery, not canonical live configuration. The resolver is technical platform infrastructure and owns no domain meaning, provider state, model state, authority, or Interaction semantics. + +Docker environment variables may carry minimal bootstrap references, such as a credential reference id or an allowed secret file path, but Docker environment is not the long-term canonical configuration store for Domovoy. Operational concepts such as provider endpoints, model bindings, routing configuration, health, project settings, capability configuration, and runtime policy must remain movable toward canonical registries without redeploy. + +## Boundaries + +- Plaintext secrets, API keys, tokens and passwords remain forbidden in source, task packets, logs, tests and ordinary environment configuration. +- Secret-file references are allowed only through typed resolver policy and fail closed outside the protected backend root. +- Canonical owners store `CredentialRef` metadata and policy only; the secret value stays in the Secret Backend. +- The resolver is not Interaction-specific and must be reusable by other trusted runtime consumers. +- Cognitive models receive no plaintext secret unless a future explicitly authorized operation requires it. + +## Consequences + +DEV-000061 may connect `interaction_role` by creating a CredentialRef and resolving it from the protected V1 local backend. This adds a minimal `platform/secrets` mechanism and typed `contracts/secrets` contract without introducing Vault, Kubernetes secrets, HSM, distributed replication, secret management UI, automatic rotation, PKI, or cluster scheduling. + diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json index d767230..7dae2f5 100644 --- a/architecture/architecture-registry.json +++ b/architecture/architecture-registry.json @@ -141,6 +141,13 @@ "may_depend_on": [ "contracts.identity" ] + }, + "interaction": { + "id": "mod.interaction", + "owns": ["Conversation", "HumanUtterance", "UtteranceInterpretation", "OriginalHumanIntent", "InteractionRelation"], + "database_schema": "interaction", + "boundary_class": "remote_capable", + "may_depend_on": ["contracts.identity", "contracts.situation", "contracts.cognitive", "platform.ids", "platform.events"] } }, "platform": { @@ -150,7 +157,8 @@ "platform.outbox", "platform.inbox", "platform.telemetry", - "platform.errors" + "platform.errors", + "platform.secrets" ], "rule": "platform owns mechanisms, never domain meaning" }, @@ -162,7 +170,8 @@ "work", "epistemics", "capability", - "artifact" + "artifact", + "interaction" ] }, "continuity_runtime": { @@ -197,7 +206,8 @@ "continuity": "continuity_role", "capability": "capability_role", "commit": "commit_role", - "artifact": "artifact_role" + "artifact": "artifact_role", + "interaction": "interaction_role" }, "cross_owner_fk": "forbidden_by_default", "cross_owner_write": "forbidden", diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml index a49f6cb..9035ef8 100644 --- a/architecture/architecture-registry.yaml +++ b/architecture/architecture-registry.yaml @@ -68,6 +68,13 @@ modules: boundary_class: remote_capable may_depend_on: [contracts.identity] + interaction: + id: mod.interaction + owns: [Conversation, HumanUtterance, UtteranceInterpretation, OriginalHumanIntent, InteractionRelation] + database_schema: interaction + boundary_class: remote_capable + may_depend_on: [contracts.identity, contracts.situation, contracts.cognitive, platform.ids, platform.events] + platform: allowed_packages: - platform.ids @@ -76,11 +83,12 @@ platform: - platform.inbox - platform.telemetry - platform.errors + - platform.secrets rule: "platform owns mechanisms, never domain meaning" runtime_topology_v1: core_runtime: - modules: [identity, situation, work, epistemics, capability, artifact] + modules: [identity, situation, work, epistemics, capability, artifact, interaction] continuity_runtime: modules: [continuity] commit_runtime: @@ -102,6 +110,7 @@ storage_topology_v1: capability: capability_role commit: commit_role artifact: artifact_role + interaction: interaction_role cross_owner_fk: forbidden_by_default cross_owner_write: forbidden cross_owner_application_join: forbidden diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index cada33b..d597605 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -3,16 +3,22 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "git.cin.su/m/Assistant/adapters/postgres" "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/interaction" + secretcontracts "git.cin.su/m/Assistant/contracts/secrets" "git.cin.su/m/Assistant/contracts/situation" "git.cin.su/m/Assistant/contracts/work" capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" identitydomain "git.cin.su/m/Assistant/modules/identity/domain" + interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" situationdomain "git.cin.su/m/Assistant/modules/situation/domain" + platformsecrets "git.cin.su/m/Assistant/platform/secrets" "git.cin.su/m/Assistant/runtime/health" + "github.com/jackc/pgx/v5" "io" "log" "net/http" @@ -63,6 +69,19 @@ func main() { log.Fatal(err) } defer identityRepository.Close() + interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF") + if err != nil { + log.Fatal(err) + } + interactionUser := os.Getenv("INTERACTION_POSTGRES_USER") + if interactionUser == "" { + interactionUser = "interaction_role" + } + interactionRepository, err := postgres.OpenInteraction(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: interactionUser, Password: interactionPassword}) + if err != nil { + log.Fatal(err) + } + defer interactionRepository.Close() mux := http.NewServeMux() authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) @@ -141,6 +160,64 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(status) }) + mux.HandleFunc("POST /v1/creator/chat/messages", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateCreator(w, r) + if !ok { + return + } + var request interaction.ChatMessageRequest + decoder := json.NewDecoder(io.LimitReader(r.Body, interactiondomain.MaxHumanUtteranceBytes+1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + http.Error(w, "invalid chat message", http.StatusBadRequest) + return + } + now := time.Now() + var conversation interaction.Conversation + if request.ConversationID == "" { + conversation, err = interactiondomain.NewConversation(session.ActorID, session.PrincipalID, now) + if err != nil { + http.Error(w, "conversation unavailable", http.StatusServiceUnavailable) + return + } + } else { + conversation, err = interactionRepository.Conversation(r.Context(), request.ConversationID) + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "conversation not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "conversation unavailable", http.StatusServiceUnavailable) + return + } + if conversation.ActorID != session.ActorID || conversation.PrincipalID != session.PrincipalID { + http.Error(w, "conversation denied", http.StatusForbidden) + return + } + conversation.UpdatedAtUnixMillis = now.UnixMilli() + } + utterance, intent, relations, err := interactiondomain.NewHumanUtterance(conversation, request, now) + if err != nil { + http.Error(w, "invalid chat message", http.StatusBadRequest) + return + } + if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil { + http.Error(w, "save chat message", http.StatusInternalServerError) + return + } + response := interaction.ChatMessageResponse{ + ConversationID: conversation.ID, + UtteranceID: utterance.ID, + SpeechAct: utterance.SpeechAct, + InterpretationState: utterance.InterpretationState, + NeedsCognition: interactiondomain.NeedsCognition(utterance.SpeechAct), + FallbackText: interactiondomain.FallbackText(utterance.SpeechAct), + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(response) + }) mux.HandleFunc("GET /v1/principal-bindings/{actor}/{principal}", func(w http.ResponseWriter, r *http.Request) { binding, err := identityRepository.LoadBinding(r.Context(), identity.ActorID(r.PathValue("actor")), identity.PrincipalID(r.PathValue("principal"))) if err != nil { @@ -498,3 +575,31 @@ func readRequiredSecret(name string) (string, error) { } return direct, nil } + +func readCredentialRefSecret(name, refName string) (string, error) { + if _, directSet := os.LookupEnv(name); directSet { + return "", fmt.Errorf("%s direct environment secret is forbidden; use %s_FILE", name, name) + } + fileName := os.Getenv(name + "_FILE") + if fileName == "" { + return "", fmt.Errorf("%s_FILE is required", name) + } + ref := os.Getenv(refName) + if ref == "" { + return "", fmt.Errorf("%s is required", refName) + } + root := os.Getenv("SECRET_BACKEND_ROOT") + if root == "" { + root = "/run/secrets" + } + resolver, err := platformsecrets.NewFileResolver(root) + if err != nil { + return "", err + } + return resolver.ResolveFile(secretcontracts.SecretFileRef{ + CredentialRef: secretcontracts.CredentialRef(ref), + Purpose: secretcontracts.SecretPurposeDatabasePassword, + Consumer: secretcontracts.RuntimeConsumerCore, + Path: fileName, + }) +} diff --git a/cmd/creator-web/chat_test.go b/cmd/creator-web/chat_test.go new file mode 100644 index 0000000..591a783 --- /dev/null +++ b/cmd/creator-web/chat_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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/interaction" +) + +func TestChatPageServedWithoutInternalIDs(t *testing.T) { + handler, err := newHandler("http://core", "http://continuity", "http://commit", http.DefaultClient, "http://cognitive") + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/chat", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("expected /chat status 200, got %d", recorder.Code) + } + body := recorder.Body.String() + for _, forbidden := range []string{"provider_id", "model_id", "binding_id", "workspace-id", "session-id"} { + if strings.Contains(body, forbidden) { + t.Fatalf("/chat leaks internal field %q", forbidden) + } + } +} + +func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) { + var cognitiveRequest cognitive.GatewayAdvisoryRequest + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current": + _ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages": + var request interaction.ChatMessageRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Message != "Кто ты?" || request.ConversationID != "" { + t.Fatalf("unexpected core chat request: %#v", request) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, InterpretationState: interaction.InterpretationInterpreted, NeedsCognition: true, FallbackText: "fallback"}) + default: + t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path) + } + })) + defer core.Close() + continuity := httptest.NewServer(http.NotFoundHandler()) + defer continuity.Close() + commit := httptest.NewServer(http.NotFoundHandler()) + defer commit.Close() + cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/advisories" { + t.Fatalf("unexpected cognitive route %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&cognitiveRequest); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitiveRequest.RequestID, AdvisoryText: "Я Домовой.", FinishReason: cognitive.FinishCompleted, ReferencedExcerptIDs: []cognitive.ContextExcerptID{}}) + })) + defer cognitiveRuntime.Close() + + handler, err := newHandler(core.URL, continuity.URL, commit.URL, core.Client(), cognitiveRuntime.URL) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Кто ты?"}`)) + request.Header.Set("Authorization", "Bearer session-token") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String()) + } + var response browserChatResponse + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if response.ReplyText != "Я Домовой." || response.ConversationID != "conversation-1" || response.SpeechAct != string(interaction.SpeechActQuestion) { + t.Fatalf("unexpected chat response: %#v", response) + } + if cognitiveRequest.DataPolicy != capability.DataPolicyLocalOnly || cognitiveRequest.EgressAcknowledged { + t.Fatalf("unexpected cognitive policy: %#v", cognitiveRequest) + } + if cognitiveRequest.ActorID != "actor-1" || cognitiveRequest.PrincipalID != "principal-1" || cognitiveRequest.ScopeID != "domovoy-global-scope" || cognitiveRequest.Message != "Кто ты?" { + t.Fatalf("unexpected trusted gateway request: %#v", cognitiveRequest) + } +} diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 04f83e1..a9d7c4b 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -14,6 +14,7 @@ import ( "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/interaction" "git.cin.su/m/Assistant/contracts/situation" "git.cin.su/m/Assistant/platform/ids" ) @@ -98,6 +99,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { g.handleAdvisory(w, r) return } + if r.Method == http.MethodPost && r.URL.Path == "/api/chat/messages" { + g.handleChatMessage(w, r) + return + } g.forwardAPI(w, r) return } @@ -108,6 +113,11 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Cache-Control", "no-store") } + if r.URL.Path == "/chat" { + r = r.Clone(r.Context()) + r.URL.Path = "/chat.html" + w.Header().Set("Cache-Control", "no-store") + } g.static.ServeHTTP(w, r) } @@ -124,6 +134,21 @@ type advisoryPolicy struct { Disclosure string } +type browserChatRequest struct { + ConversationID string `json:"conversation_id"` + Message string `json:"message"` + CorrelationID string `json:"correlation_id"` + CorrectsUtteranceID string `json:"corrects_utterance_id"` +} + +type browserChatResponse struct { + ConversationID string `json:"conversation_id"` + UtteranceID string `json:"utterance_id"` + SpeechAct string `json:"speech_act"` + ReplyText string `json:"reply_text"` + Cognition string `json:"cognition"` +} + func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { authorization := r.Header.Get("Authorization") if authorization == "" { @@ -249,6 +274,68 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(advisory) } +func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) { + var browserRequest browserChatRequest + if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || strings.TrimSpace(browserRequest.Message) == "" || len(browserRequest.Message) > 16*1024 { + http.Error(w, "invalid chat message", 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 || session.ActorID == "" || session.PrincipalID == "" { + http.Error(w, "creator session denied", status) + return + } + coreRequest := interaction.ChatMessageRequest{ConversationID: interaction.ConversationID(browserRequest.ConversationID), Message: browserRequest.Message, CorrelationID: browserRequest.CorrelationID, CorrectsUtteranceID: interaction.HumanUtteranceID(browserRequest.CorrectsUtteranceID)} + payload, err := json.Marshal(coreRequest) + if err != nil { + http.Error(w, "chat unavailable", http.StatusServiceUnavailable) + return + } + var saved interaction.ChatMessageResponse + status, err = g.postTrustedJSON(r, g.core, "/v1/creator/chat/messages", authorization, payload, &saved) + if err != nil { + http.Error(w, "chat unavailable", http.StatusBadGateway) + return + } + if status != http.StatusCreated { + http.Error(w, "chat unavailable", status) + return + } + reply := saved.FallbackText + cognition := "not_requested" + if saved.NeedsCognition { + cognition = "unavailable" + requestID, idErr := ids.New() + if idErr == nil { + trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false} + payload, err = json.Marshal(trusted) + if err == nil { + var advisory cognitive.GatewayAdvisoryResponse + status, err = g.postTrustedJSON(r, g.cognitive, "/v1/advisories", "", payload, &advisory) + if err == nil && status == http.StatusOK && validAdvisoryResponse(advisory, trusted) { + cognition = string(advisory.FinishReason) + if advisory.FinishReason == cognitive.FinishCompleted { + reply = advisory.AdvisoryText + } + } + } + } + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(browserChatResponse{ConversationID: string(saved.ConversationID), UtteranceID: string(saved.UtteranceID), SpeechAct: string(saved.SpeechAct), ReplyText: reply, Cognition: cognition}) +} + func validAdvisoryResponse(response cognitive.GatewayAdvisoryResponse, request cognitive.GatewayAdvisoryRequest) bool { if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || len(response.ReferencedExcerptIDs) != 0 { return false @@ -290,6 +377,32 @@ func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQu return response.StatusCode, nil } +func (g *gateway) postTrustedJSON(r *http.Request, upstream *url.URL, path, authorization string, payload []byte, destination any) (int, error) { + target := *upstream + target.Path = path + request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload))) + if err != nil { + return 0, err + } + request.Header.Set("Content-Type", "application/json") + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + response, err := g.client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode > 299 { + _, _ = 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() diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css new file mode 100644 index 0000000..ef0dc60 --- /dev/null +++ b/cmd/creator-web/static/assets/chat.css @@ -0,0 +1,49 @@ +:root { + --ink: #e9eee7; + --muted: #93a096; + --base: #0b100e; + --panel: #101714; + --line: #2a352f; + --signal: #c9a85f; + --bad: #de816f; + --good: #78c6a3; + --radius: 3px; + --sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif; + --serif: Georgia, "Times New Roman", serif; + --mono: "Cascadia Mono", Consolas, monospace; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); } +button, input, textarea { font: inherit; } +button { cursor: pointer; color: inherit; border-radius: var(--radius); } +.chat-shell { min-height: 100vh; display: grid; } +.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); } +.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); } +h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; } +label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; } +input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; } +input { height: 48px; padding: 0 12px; } +textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; } +.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; } +.login-form button { width: 100%; height: 50px; margin-top: 18px; } +#login-error { min-height: 20px; color: var(--bad); font-size: 13px; } +.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr) auto; } +.chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); } +.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); } +.chat-header span { margin-left: auto; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } +.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; } +.messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: auto; } +.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; } +.message.user { align-self: flex-end; background: #151e1a; } +.message.assistant { align-self: flex-start; background: #0f1613; } +.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; } +.composer { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); } +.composer button { height: 48px; align-self: end; } +.composer button:disabled { cursor: wait; filter: saturate(.3); } +@media (max-width: 640px) { + .composer { grid-template-columns: 1fr; } + .composer button { width: 100%; } + .chat-header span { display: none; } +} diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js new file mode 100644 index 0000000..12a88a1 --- /dev/null +++ b/cmd/creator-web/static/assets/chat.js @@ -0,0 +1,97 @@ +const loginView = document.querySelector("#login-view"); +const chatView = document.querySelector("#chat-view"); +const loginForm = document.querySelector("#login-form"); +const loginError = document.querySelector("#login-error"); +const messages = document.querySelector("#messages"); +const chatForm = document.querySelector("#chat-form"); +const messageInput = document.querySelector("#message"); +const sendButton = document.querySelector("#send"); +const logoutButton = document.querySelector("#logout"); + +let token = sessionStorage.getItem("domovoy.chat.token") || ""; +let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || ""; + +function showChat() { + loginView.hidden = true; + chatView.hidden = false; + if (!messages.children.length) { + appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов."); + } + messageInput.focus(); +} + +function showLogin() { + chatView.hidden = true; + loginView.hidden = false; +} + +function appendMessage(kind, text, meta = "") { + const node = document.createElement("div"); + node.className = `message ${kind}`; + node.textContent = text; + if (meta) { + const small = document.createElement("small"); + small.textContent = meta; + node.appendChild(small); + } + messages.appendChild(node); + messages.scrollTop = messages.scrollHeight; +} + +async function api(path, options = {}) { + const headers = {"Content-Type": "application/json", ...(options.headers || {})}; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(path, {...options, headers}); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + loginError.textContent = ""; + const username = document.querySelector("#username").value; + const password = document.querySelector("#password").value; + try { + const result = await api("/api/session", {method: "POST", body: JSON.stringify({username, password})}); + token = result.Token || result.token; + sessionStorage.setItem("domovoy.chat.token", token); + showChat(); + } catch { + loginError.textContent = "Вход не выполнен"; + } +}); + +chatForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const text = messageInput.value.trim(); + if (!text) return; + appendMessage("user", text); + messageInput.value = ""; + sendButton.disabled = true; + try { + const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: text})}); + conversationID = result.conversation_id; + sessionStorage.setItem("domovoy.chat.conversation", conversationID); + appendMessage("assistant", result.reply_text || "Сообщение записано.", `${result.speech_act}; cognition=${result.cognition}`); + } catch { + appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime."); + } finally { + sendButton.disabled = false; + messageInput.focus(); + } +}); + +logoutButton.addEventListener("click", () => { + token = ""; + conversationID = ""; + sessionStorage.removeItem("domovoy.chat.token"); + sessionStorage.removeItem("domovoy.chat.conversation"); + messages.replaceChildren(); + showLogin(); +}); + +if (token) { + showChat(); +} else { + showLogin(); +} diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html new file mode 100644 index 0000000..6f0a38b --- /dev/null +++ b/cmd/creator-web/static/chat.html @@ -0,0 +1,41 @@ + + +
+ + + + +