Compare commits

..
Author SHA1 Message Date
m 6b297c7749 fix: wire cognitive medium to local chat gateway
CI / test (push) Failing after 33s
2026-08-29 19:28:37 +03:00
m 91b8da2240 merge: integrate frozen Domovoy UI and identity auth
CI / test (push) Failing after 28s
2026-08-29 17:31:41 +03:00
m 8924f5a48a Move creator passwords into identity accounts
CI / test (push) Failing after 27s
2026-08-29 17:02:42 +03:00
m 636fb904f2 refactor: fix secret file paths in code
CI / test (push) Failing after 27s
2026-08-29 16:51:50 +03:00
m 598a97cd1a fix: keep sessions during cognitive probe failures
CI / test (push) Failing after 26s
2026-08-29 16:22:05 +03:00
m e9ef635933 fix: simplify chat composer actions
CI / test (push) Failing after 27s
2026-08-29 16:10:59 +03:00
m c516754d63 fix: keep attachment tools neutral
CI / test (push) Failing after 28s
2026-08-29 16:00:09 +03:00
m 5c965ef69c fix: make Domovoy chat-first
CI / test (push) Failing after 29s
2026-08-29 15:58:19 +03:00
m eab7d11bdd fix: refine chat composer controls
CI / test (push) Failing after 26s
2026-08-29 15:42:52 +03:00
m 51a49ba0ef fix: make chat composer compact and keyboard friendly
CI / test (push) Failing after 25s
2026-08-29 15:38:28 +03:00
m 60ff22882c fix: enable starting a fresh chat
CI / test (push) Failing after 28s
2026-08-29 15:28:38 +03:00
m 9896e4f716 feat: redesign Domovoy chat as calm dark cockpit
CI / test (push) Failing after 28s
2026-08-29 15:26:54 +03:00
m ff0d0e585b merge: deploy context retrieval remediation
CI / test (push) Failing after 27s
2026-08-29 15:08:04 +03:00
m 13f3165ba0 merge: integrate context retrieval remediation
CI / test (push) Failing after 28s
2026-08-29 15:07:59 +03:00
m 55f2f9affa fix: project explicit remembered assertions
CI / test (push) Failing after 26s
2026-08-29 15:07:47 +03:00
m e47e033dda merge: synchronize integration with current main
CI / test (push) Failing after 31s
2026-08-29 14:52:15 +03:00
m 2dc5587fd1 Clarify chat response progress
CI / test (push) Failing after 30s
2026-08-29 14:31:03 +03:00
m 992c84240b Preserve chat responsive guardrail 2026-08-29 14:30:29 +03:00
m cfeb6aeba7 Put chat first on mobile screens 2026-08-29 14:30:10 +03:00
m f0d45ca9f1 Clarify chat message roles and timing 2026-08-29 14:29:39 +03:00
m e20c9fdfc0 Improve Domovoy chat composer usability 2026-08-29 14:28:32 +03:00
m cb50a120e5 merge: record Gen2 deployment recovery evidence
CI / test (push) Failing after 29s
2026-08-29 12:49:59 +03:00
m b30eb38eca docs: record Gen2 deployment recovery evidence
CI / test (push) Failing after 28s
2026-08-29 12:49:51 +03:00
m 2083096aed merge: preserve stable rollback target
CI / test (push) Failing after 28s
2026-08-29 12:44:33 +03:00
m 671ecb4968 fix(supervisor): preserve stable rollback target
CI / test (push) Failing after 32s
2026-08-29 12:44:25 +03:00
m af9d5809e9 merge: stabilize supervisor control contract
CI / test (push) Failing after 27s
2026-08-29 12:41:30 +03:00
m 990928618d fix(supervisor): stabilize control JSON contract
CI / test (push) Failing after 31s
2026-08-29 12:41:22 +03:00
m 9fac8284f5 merge: initialize supervisor state volume permissions
CI / test (push) Failing after 27s
2026-08-29 12:39:01 +03:00
m 7d9a5edee5 fix(deploy): initialize supervisor volume permissions
CI / test (push) Failing after 32s
2026-08-29 12:38:53 +03:00
m 32d3f318a5 merge: independent supervisor control plane
CI / test (push) Failing after 31s
2026-08-29 12:33:54 +03:00
25 changed files with 976 additions and 222 deletions
+36
View File
@@ -26,3 +26,39 @@ func (r *IdentityRepository) LoadBinding(ctx context.Context, actor identity.Act
}
return binding, nil
}
func (r *IdentityRepository) LoadCreatorAccount(ctx context.Context, username string) (identity.CreatorAccount, error) {
var account identity.CreatorAccount
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE username=$1`, username).
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
if err != nil {
return identity.CreatorAccount{}, fmt.Errorf("load creator account: %w", err)
}
return account, nil
}
func (r *IdentityRepository) LoadCreatorAccountByBinding(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID) (identity.CreatorAccount, error) {
var account identity.CreatorAccount
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE actor_id=$1 AND principal_id=$2`, actor, principal).
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
if err != nil {
return identity.CreatorAccount{}, fmt.Errorf("load creator account by binding: %w", err)
}
return account, nil
}
func (r *IdentityRepository) CreateCreatorAccount(ctx context.Context, account identity.CreatorAccount) error {
_, err := r.pool.Exec(ctx, `INSERT INTO identity.creator_accounts (username, actor_id, principal_id, password_hash) VALUES ($1,$2,$3,$4) ON CONFLICT (username) DO NOTHING`, account.Username, account.ActorID, account.PrincipalID, account.PasswordHash)
return err
}
func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID, revision int64, passwordHash string) error {
result, err := r.pool.Exec(ctx, `UPDATE identity.creator_accounts SET password_hash=$1, revision=revision+1, password_changed_at=CURRENT_TIMESTAMP WHERE actor_id=$2 AND principal_id=$3 AND revision=$4`, passwordHash, actor, principal, revision)
if err != nil {
return fmt.Errorf("update creator password: %w", err)
}
if result.RowsAffected() != 1 {
return fmt.Errorf("update creator password: revision conflict")
}
return nil
}
+155 -19
View File
@@ -37,6 +37,14 @@ import (
"time"
)
const (
creatorPasswordPath = "/run/secrets/creator_password"
creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token"
creatorSessionKeyPath = "/run/secrets/creator_session_signing_key"
epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
interactionDBPasswordPath = "/run/secrets/interaction_db_password"
)
const maxCreatorCredentialsBytes = 4 << 10
func main() {
@@ -44,20 +52,19 @@ func main() {
if addr == "" {
addr = ":8080"
}
bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN")
bootstrapToken, err := readRequiredSecretFile("CREATOR_BOOTSTRAP_TOKEN", creatorBootstrapTokenPath)
if err != nil {
log.Fatal(err)
}
sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY")
if err != nil {
log.Fatal(err)
}
creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD")
sessionKeyValue, err := readRequiredSecretFile("CREATOR_SESSION_SIGNING_KEY", creatorSessionKeyPath)
if err != nil {
log.Fatal(err)
}
sessionKey := []byte(sessionKeyValue)
creatorUsername := os.Getenv("CREATOR_USERNAME")
if creatorUsername == "" {
log.Fatal("CREATOR_USERNAME is required")
}
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
@@ -73,7 +80,7 @@ func main() {
log.Fatal(err)
}
defer capabilityRepository.Close()
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF", epistemicsDBPasswordPath)
if err != nil {
log.Fatal(err)
}
@@ -91,7 +98,10 @@ func main() {
log.Fatal(err)
}
defer identityRepository.Close()
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF")
if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil {
log.Fatal(err)
}
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath)
if err != nil {
log.Fatal(err)
}
@@ -177,7 +187,39 @@ func main() {
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now))
mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
var request struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || request.CurrentPassword == "" || request.NewPassword == "" {
http.Error(w, "invalid password change request", http.StatusBadRequest)
return
}
account, err := identityRepository.LoadCreatorAccountByBinding(r.Context(), session.ActorID, session.PrincipalID)
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, request.CurrentPassword) {
http.Error(w, "current password denied", http.StatusUnauthorized)
return
}
newHash, err := identitydomain.HashCreatorPassword(request.NewPassword)
if err != nil {
http.Error(w, "invalid new password", http.StatusBadRequest)
return
}
if err := identityRepository.UpdateCreatorPassword(r.Context(), session.ActorID, session.PrincipalID, account.Revision, newHash); err != nil {
http.Error(w, "password change conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "changed"})
})
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
@@ -1704,6 +1746,88 @@ func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword stri
}
}
func creatorDatabaseSessionHandler(bootstrapToken, creatorUsername string, repository *postgres.IdentityRepository, sessionKey []byte, now func() time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) {
token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
writeCreatorSession(w, token, session)
return
}
var credentials identity.CreatorCredentials
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
account, err := repository.LoadCreatorAccount(r.Context(), credentials.Username)
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, credentials.Password) {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
binding, err := repository.LoadBinding(r.Context(), account.ActorID, account.PrincipalID)
if err != nil || !binding.Active {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
token, session, err := identitydomain.IssueCreatorSessionForBinding(sessionKey, account.ActorID, account.PrincipalID, now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
writeCreatorSession(w, token, session)
}
}
func writeCreatorSession(w http.ResponseWriter, token string, session identity.CreatorSession) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Token string
Session identity.CreatorSession
}{token, session})
}
func bootstrapCreatorAccount(ctx context.Context, repository *postgres.IdentityRepository, username, passwordPath string) error {
if account, err := repository.LoadCreatorAccount(ctx, username); err == nil {
if account.ActorID == "" || account.PrincipalID == "" {
return errors.New("creator account binding is incomplete")
}
return nil
} else if !errors.Is(unwrapIdentityRepositoryError(err), pgx.ErrNoRows) {
return err
}
password, err := readRequiredSecretFile("CREATOR_PASSWORD", passwordPath)
if err != nil {
return fmt.Errorf("creator account is missing and bootstrap password is unavailable: %w", err)
}
hash, err := identitydomain.HashCreatorPassword(password)
if err != nil {
return fmt.Errorf("hash creator bootstrap password: %w", err)
}
if err := repository.CreateCreatorAccount(ctx, identity.CreatorAccount{Username: username, ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", PasswordHash: hash}); err != nil {
return fmt.Errorf("create creator account: %w", err)
}
return nil
}
func unwrapIdentityRepositoryError(err error) error {
for err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return pgx.ErrNoRows
}
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
break
}
err = unwrapped
}
return err
}
func bearerToken(r *http.Request) string {
value := r.Header.Get("Authorization")
if !strings.HasPrefix(value, "Bearer ") {
@@ -1726,10 +1850,7 @@ func readRequiredSecret(name string) (string, error) {
if err != nil {
return "", fmt.Errorf("read %s_FILE: %w", name, err)
}
direct = strings.TrimRight(string(contents), "\r\n")
if strings.ContainsAny(direct, "\r\n") {
return "", fmt.Errorf("%s_FILE must contain one line", name)
}
return normalizeSecretFile(name, contents)
}
if direct == "" {
return "", fmt.Errorf("%s is required", name)
@@ -1737,13 +1858,28 @@ 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)
func readRequiredSecretFile(name, path string) (string, error) {
contents, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s secret file: %w", name, err)
}
fileName := os.Getenv(name + "_FILE")
if fileName == "" {
return "", fmt.Errorf("%s_FILE is required", name)
return normalizeSecretFile(name, contents)
}
func normalizeSecretFile(name string, contents []byte) (string, error) {
value := strings.TrimRight(string(contents), "\r\n")
if strings.ContainsAny(value, "\r\n") {
return "", fmt.Errorf("%s secret file must contain one line", name)
}
if value == "" {
return "", fmt.Errorf("%s is required", name)
}
return value, nil
}
func readCredentialRefSecret(name, refName, fileName string) (string, error) {
if _, directSet := os.LookupEnv(name); directSet {
return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name)
}
ref := os.Getenv(refName)
if ref == "" {
+2
View File
@@ -841,6 +841,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
return targetCore, "/v1/creator/sessions", true
case method == http.MethodGet && path == "/api/session/current":
return targetCore, "/v1/creator/sessions/current", true
case method == http.MethodPost && path == "/api/account/password":
return targetCore, "/v1/creator/account/password", true
case method == http.MethodGet && path == "/api/focus":
return targetCore, "/v1/creator/focus", true
case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"):
+260 -106
View File
@@ -1,131 +1,285 @@
: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;
--ink: #e6edf4;
--muted: #8494a4;
--faint: #5d6e7f;
--base: #0b1119;
--panel: #111b27;
--panel-raised: #172331;
--panel-soft: #0f1925;
--line: #263544;
--line-strong: #34485c;
--lime: #c5ef91;
--lime-ink: #142014;
--blue: #8fc7ef;
--yellow: #d8b86c;
--good: #8ed7ad;
--bad: #ee9292;
--radius: 8px;
--sans: "Manrope", "Segoe UI", sans-serif;
--mono: "DM 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); }
a { color: inherit; }
.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-panel { width: min(100% - 32px, 420px); margin: auto; padding: 40px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.28); }
.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; color: var(--lime-ink); background: var(--lime); border-radius: 10px; font: 700 22px/1 var(--sans); }
h1, h2 { margin: 0; letter-spacing: -.03em; }
.login-panel h1 { margin: 26px 0; font-size: 42px; font-weight: 650; }
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-soft); outline: none; }
input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.12); }
input { height: 46px; padding: 0 12px; }
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; line-height: 1.45; }
.login-form button, .composer button { border: 0; color: var(--lime-ink); background: var(--lime); font-weight: 750; }
.login-form button { width: 100%; height: 46px; margin-top: 18px; }
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); }
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
#active-focus { color: var(--good); }
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 60px minmax(0, 1fr); overflow: hidden; }
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(17,27,39,.97); }
.chat-header > a:first-child { color: var(--ink); text-decoration: none; font-size: 18px; font-weight: 750; letter-spacing: -.02em; }
.cognitive-status { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.chat-header .cognitive-status { width: auto; height: auto; padding: 0; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.chat-header .cognitive-status { max-width: min(30vw, 320px); margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--signal); }
.chat-header .cognitive-status { width: auto; height: auto; max-width: min(30vw, 340px); margin-left: auto; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .04em; text-overflow: ellipsis; text-transform: uppercase; }
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--yellow); }
.cognitive-status[data-state="available"] { color: var(--good); }
.cognitive-status[data-state="available"]::before { background: var(--good); }
.cognitive-status[data-state="unavailable"] { color: var(--bad); }
.cognitive-status[data-state="unavailable"]::before { background: var(--bad); }
.cognitive-console-link { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; }
.build-meta time { color: #68776d; font-size: 8px; }
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
.human-nav { display: grid; gap: 8px; }
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.chat-left .nav-tab { height: auto; min-height: 58px; padding: 10px 12px; display: grid; grid-template-columns: 28px 1fr; gap: 2px 8px; align-items: center; text-align: left; }
.chat-left .nav-tab b { grid-row: span 2; color: var(--signal); font: 11px/1 var(--mono); }
.chat-left .nav-tab span { color: var(--ink); font: 600 13px/1 var(--sans); text-transform: none; letter-spacing: 0; }
.chat-left .nav-tab small { color: var(--muted); font: 10px/1 var(--mono); text-transform: none; letter-spacing: 0; }
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
.operator-link { color: var(--good); }
.chat-header > .operator-link { flex: 0 0 auto; }
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(250px, 320px); }
.chat-side { min-width: 0; min-height: 0; padding: 28px 20px; overflow-y: auto; background: #0e1512; }
.generated-image { display: block; max-width: min(100%, 640px); height: auto; margin-top: 12px; border: 1px solid var(--line); }
.cognitive-console-link { color: var(--blue); font: 10px/1 var(--mono); letter-spacing: .04em; white-space: nowrap; }
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .04em; text-align: right; text-transform: uppercase; white-space: nowrap; }
.build-meta time { color: var(--faint); font-size: 8px; }
.chat-header button { width: 34px; height: 34px; border: 1px solid var(--line); background: transparent; }
.operator-link { height: 32px; padding: 0 11px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--blue); background: transparent; text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .04em; text-transform: uppercase; }
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 258px) minmax(0, 1fr) minmax(270px, 320px); }
.chat-side { min-width: 0; min-height: 0; padding: 22px 18px; overflow-y: auto; background: var(--panel); scrollbar-color: var(--line-strong) transparent; }
.chat-left { border-right: 1px solid var(--line); }
.chat-right { border-left: 1px solid var(--line); }
.side-intro h1, .side-intro h2 { margin: 12px 0; font: 400 clamp(28px, 2.5vw, 40px)/.95 var(--serif); letter-spacing: -.03em; }
.side-intro p:not(.side-index) { color: var(--muted); line-height: 1.5; }
.side-index, .side-focus span, .side-note span, .ai-info-card span { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.side-focus, .side-note, .ai-info-card { margin-top: 28px; padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.side-focus strong, .side-note strong, .ai-info-card strong { display: block; margin-top: 9px; color: var(--ink); font: 600 14px/1.3 var(--sans); }
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; }
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; }
.side-intro h1, .side-intro h2 { margin: 9px 0 10px; font-size: clamp(25px, 2.4vw, 34px); line-height: 1.05; font-weight: 680; }
.side-intro p:not(.side-index) { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.side-index, .section-label, .ai-info-card span, .insight-card span { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.side-section, .side-note, .ai-info-card { margin-top: 20px; padding: 13px 0; border-top: 1px solid var(--line); }
.side-focus strong, .side-note strong { display: block; margin-top: 8px; color: var(--blue); font-size: 13px; font-weight: 650; line-height: 1.35; }
.side-note { border-bottom: 0; }
.side-note strong { color: var(--good); }
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.45; }
.context-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.context-list span { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); font-size: 11px; }
.quick-context { padding-bottom: 3px; }
.human-nav { display: grid; gap: 6px; margin-top: 8px; }
.nav-tab { width: 100%; min-height: 50px; padding: 8px 10px; display: grid; grid-template-columns: 26px 1fr; gap: 2px 7px; align-items: center; border: 1px solid transparent; color: var(--muted); background: transparent; text-align: left; }
.chat-left .nav-tab b { grid-row: span 2; color: var(--faint); font: 10px/1 var(--mono); }
.chat-left .nav-tab span { color: var(--ink); font-size: 12px; font-weight: 650; }
.chat-left .nav-tab small { color: var(--faint); font: 10px/1 var(--mono); }
.nav-tab:hover, .nav-tab:focus-visible { border-color: var(--line-strong); outline: none; }
.nav-tab.active { border-color: rgba(197,239,145,.42); background: rgba(197,239,145,.1); }
.nav-tab.active b { color: var(--lime); }
.quiet-state { margin: 9px 0 0; color: var(--faint); font-size: 11px; }
.new-conversation { width: 100%; min-height: 34px; margin-top: 9px; border: 1px solid var(--line-strong); color: var(--ink); background: transparent; font-size: 11px; }
.new-conversation:hover, .new-conversation:focus-visible { border-color: var(--lime); color: var(--lime); outline: none; }
.side-build { margin-top: 14px; padding-top: 14px; text-align: left; }
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--base); }
.surface-panel { min-height: 0; position: relative; overflow-y: auto; }
.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; }
.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: 28px 18px; display: flex; flex-direction: column; gap: 14px; overflow-y: 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 { width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 10px; 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-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
.media-tools input { max-width: 150px; font: 10px var(--mono); }
.media-tools span { color: var(--signal); }
.conversation-starters { width: min(100%, 920px); margin: -4px auto 0; padding: 0 18px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.conversation-starters button { min-height: 32px; padding: 0 10px; border: 1px solid var(--line); color: var(--ink); background: #101814; font: 10px/1 var(--mono); }
.conversation-starters button:hover, .conversation-starters button:focus-visible { border-color: var(--signal); color: var(--signal); outline: none; }
.ai-data-form { margin-top: 24px; }
.ai-data-form textarea { min-height: 150px; resize: vertical; }
.ai-data-form button { width: 100%; min-height: 42px; margin-top: 12px; border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
.ai-data-error { margin: 10px 0 0; color: var(--bad); font-size: 12px; line-height: 1.4; }
.side-console-link { display: flex; justify-content: space-between; margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--signal) !important; font: 11px/1.3 var(--mono) !important; text-transform: uppercase; letter-spacing: .06em; }
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
.attention-list, .history-list { display: grid; gap: 12px; }
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
.history-item.user { border-color: #34443c; background: #151e1a; }
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
.empty-state { margin: 24px 0 0; color: var(--muted); }
.messages { width: min(100%, 900px); min-height: 0; margin: 0 auto; padding: clamp(24px, 5vh, 52px) 22px 20px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; scrollbar-color: var(--line-strong) transparent; }
.message { max-width: min(700px, 92%); padding: 11px 14px 12px; border: 1px solid var(--line); border-radius: 10px; line-height: 1.5; }
.message.user { align-self: flex-end; background: #162231; border-color: #30465b; }
.message.assistant { align-self: flex-start; background: #121d2a; }
.message-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
.message-role { color: var(--blue); font: 650 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.message.user .message-role { color: var(--lime); }
.message-time { color: var(--faint); font: 9px/1 var(--mono); }
.message-content { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
.message small { display: block; margin-top: 8px; color: var(--faint); font: 9px/1.2 var(--mono); letter-spacing: .03em; text-transform: none; }
.composer { width: min(100%, 900px); margin: 0 auto; padding: 10px 22px 7px; display: grid; grid-template-columns: minmax(0, 1fr) 106px; gap: 9px; border-top: 1px solid var(--line); background: var(--base); }
.composer-input-wrap { min-width: 0; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-soft); transition: border-color .15s ease, box-shadow .15s ease; }
.composer-input-wrap:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.1); }
.composer textarea { min-height: 46px; max-height: 200px; resize: none; border: 0; background: transparent; }
.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 7px; color: var(--faint); font: 9px/1 var(--mono); }
.composer-hint output { color: var(--muted); white-space: nowrap; }
.composer button { height: 46px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 7px; padding: 0 13px; }
.composer button[aria-busy="true"] { color: #384529; background: #a6ca7a; }
.composer button span:last-child { opacity: .65; font: 15px/1 var(--mono); }
.composer button:disabled { cursor: wait; filter: saturate(.35); }
.media-tools { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 9px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .03em; text-transform: uppercase; }
.media-tools label { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; margin: 0; padding: 0 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); cursor: pointer; }
.media-tools input { max-width: 125px; font: 9px var(--mono); }
.media-tools span { color: var(--yellow); }
.insight-grid { display: grid; gap: 7px; margin-top: 19px; }
.insight-card { padding: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-soft); }
.insight-card strong { display: block; margin-top: 7px; color: var(--ink); font-size: 12px; font-weight: 650; line-height: 1.3; }
.insight-card small { display: block; margin-top: 5px; color: var(--faint); font-size: 10px; line-height: 1.35; }
.ai-info-card { padding-bottom: 0; }
.ai-info-card strong { display: block; margin-top: 8px; font-size: 12px; }
.ai-data-form { margin-top: 18px; }
.ai-data-form textarea { min-height: 126px; }
.ai-data-form button { width: 100%; min-height: 38px; margin-top: 9px; border: 1px solid rgba(197,239,145,.3); border-radius: 7px; color: var(--lime); background: rgba(197,239,145,.1); font-size: 11px; font-weight: 700; }
.ai-data-form button:hover { background: rgba(197,239,145,.16); }
.ai-data-error { margin: 9px 0 0; color: var(--bad); font-size: 11px; line-height: 1.4; }
.side-console-link { display: flex; justify-content: space-between; margin-top: 20px; padding-top: 13px; border-top: 1px solid var(--line); color: var(--blue) !important; font: 10px/1.3 var(--mono) !important; text-decoration: none; letter-spacing: .03em; text-transform: uppercase; }
.attention-surface, .history-surface { width: min(100%, 900px); margin: 0 auto; padding: 28px 22px; align-content: start; gap: 15px; }
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 13px; }
.surface-heading p { margin: 0; font-size: 27px; font-weight: 680; }
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 9px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.surface-heading button { height: 31px; padding: 0 10px; border: 1px solid var(--line); background: transparent; font-size: 11px; }
.attention-list, .history-list { display: grid; gap: 9px; }
.attention-card, .history-item { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); padding: 13px; }
.attention-card time { color: var(--yellow); font: 10px/1 var(--mono); }
.attention-card h2 { margin: 8px 0 6px; font-size: 18px; font-weight: 650; }
.attention-card p, .history-item p { margin: 0 0 8px; color: var(--ink); font-size: 12px; line-height: 1.5; }
.attention-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; }
.attention-actions button { height: 30px; padding: 0 10px; border: 1px solid var(--line); background: var(--panel-raised); color: var(--ink); font: 10px/1 var(--mono); }
.attention-actions button:first-child { border-color: rgba(142,215,173,.5); color: var(--good); }
.history-item.user { border-color: #30465b; background: #162231; }
.history-item strong { display: block; margin-bottom: 6px; color: var(--blue); font-size: 12px; }
.empty-state { margin: 20px 0 0; color: var(--muted); font-size: 12px; }
.empty-state.error { color: var(--bad); }
@media (max-width: 640px) {
@media (max-width: 780px) {
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
.composer { grid-template-columns: 1fr; }
.chat-header { position: sticky; top: 0; z-index: 3; min-height: 58px; padding: 0 12px; }
.chat-header .cognitive-status { flex: 1 1 100px; min-width: 0; max-width: 150px; }
.chat-header .cognitive-console-link, .chat-header > .operator-link, .chat-header .build-meta { display: none; }
.chat-header > #logout { flex: 0 0 34px; }
.chat-layout { height: auto; display: flex; flex-direction: column; }
.chat-main { order: 1; height: 72vh; min-height: 530px; }
.chat-side { min-height: auto; max-height: none; overflow: visible; }
.chat-left { order: 2; border: 0; border-top: 1px solid var(--line); }
.chat-right { order: 3; border: 0; border-top: 1px solid var(--line); }
.messages { padding-inline: 12px; }
.composer { grid-template-columns: 1fr; padding-inline: 12px; }
.composer button { width: 100%; }
.conversation-starters { align-items: stretch; }
.conversation-starters span { width: 100%; }
.chat-header { gap: 10px; padding: 0 12px; }
.chat-header .cognitive-status { display: inline-flex; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.chat-header > .operator-link, .build-meta { display: none; }
.chat-header > #logout { flex: 0 0 38px; }
.composer-hint { font-size: 8px; }
.media-tools { padding-inline: 12px; }
.side-build { display: none; }
}
@media (max-width: 640px) {
.chat-layout { height: auto; display: block; }
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
.chat-main { height: 70vh; min-height: 520px; }
.chat-left { padding-bottom: 20px; }
.chat-right { padding-top: 20px; }
.operator-link, .nav-tab { padding: 0 9px; }
.chat-header > .operator-link, .build-meta { display: none; }
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
}
@media (min-width: 641px) and (max-width: 1100px) {
.chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; }
@media (min-width: 781px) and (max-width: 1100px) {
.chat-layout { grid-template-columns: 218px minmax(0, 1fr) 260px; }
.chat-side { padding-inline: 14px; }
.side-intro h1, .side-intro h2 { font-size: 30px; }
}
/* Chat-first pass: navigation and context stay out of the conversation's way. */
.chat-header { gap: 10px; padding-inline: 18px; }
.header-icon, .header-control, .profile-button { height: 32px; border: 1px solid var(--line); background: transparent; font: 10px/1 var(--mono); }
.header-icon { width: 32px; padding: 0; color: var(--muted); font-size: 15px; }
.header-control, .profile-button { padding: 0 10px; color: var(--muted); }
.header-control { color: var(--ink); }
.header-icon:hover, .header-icon:focus-visible, .header-control:hover, .header-control:focus-visible, .profile-button:hover, .profile-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.header-focus { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.chat-header .cognitive-status { margin-left: auto; max-width: 250px; }
.chat-header .cognitive-console-link { display: none; }
.chat-layout { grid-template-columns: 242px minmax(0, 1fr); }
.chat-layout.nav-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.chat-left { padding: 24px 16px; transition: opacity .18s ease, padding .18s ease, transform .18s ease; }
.chat-layout.nav-collapsed .chat-left { padding-inline: 0; overflow: hidden; opacity: 0; border-right: 0; }
.side-intro h1 { font-size: 26px; }
.side-section { margin-top: 18px; }
.human-nav { margin-top: 12px; }
.nav-tab { grid-template-columns: 1fr; min-height: 44px; padding-inline: 10px; }
.chat-left .nav-tab b { display: none; }
.new-conversation { margin-top: 16px; }
.chat-main { min-width: 0; }
.messages { width: min(100%, 1040px); padding: 32px 30px 22px; gap: 14px; }
.message { max-width: min(760px, 88%); padding: 14px 17px 15px; border-radius: 12px; }
.message-content { font-size: 15px; line-height: 1.55; }
.message-role { font-size: 10px; }
.message-time { font-size: 10px; }
.message small { font-size: 10px; }
.composer { width: min(100%, 1040px); padding-inline: 30px; }
.composer-input-wrap { min-height: 64px; padding: 0 12px 36px; border-radius: 14px; }
.composer textarea { min-height: 56px; max-height: 180px; padding: 13px 0 8px; font-size: 15px; }
.composer-actions { right: 9px; bottom: 8px; left: 9px; height: 28px; }
.composer-tool { width: 29px; height: 29px; padding: 0; border: 0; color: var(--muted); background: transparent !important; }
.composer-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
.composer-tool:hover, .composer-tool:focus-visible { border: 0; color: var(--blue); box-shadow: none; }
.composer-hint { font-size: 10px; }
.send-button { width: 33px; height: 33px; flex-basis: 33px; }
.composer-runtime { height: 0; min-height: 0; padding: 0; overflow: visible; }
.attachment-menu { bottom: 42px; left: 8px; }
.context-indicator { display: none; }
.technical-meta { display: none !important; }
.context-backdrop { position: fixed; z-index: 8; inset: 60px 0 0; border: 0; background: rgba(4,9,15,.58); }
.context-drawer { position: fixed; z-index: 9; top: 60px; right: 0; bottom: 0; width: min(360px, 92vw); padding: 24px 20px; border-left: 1px solid var(--line-strong); box-shadow: -20px 0 50px rgba(0,0,0,.28); transform: translateX(105%); transition: transform .2s ease; }
.password-dialog { width: min(420px, calc(100vw - 32px)); padding: 0; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 24px 80px rgba(0,0,0,.45); }
.password-dialog::backdrop { background: rgba(3,8,14,.68); }
.password-dialog form { padding: 22px; }
.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.dialog-heading h2 { margin: 0; font-size: 20px; }
.dialog-heading button { width: 30px; height: 30px; border: 1px solid var(--line); background: transparent; }
.password-dialog label { display: block; margin: 14px 0 7px; color: var(--muted); font: 11px/1 var(--mono); }
.password-dialog input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-raised); }
.password-dialog input:focus { outline: 2px solid var(--blue); outline-offset: 1px; }
.dialog-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; }
.dialog-actions button { min-height: 38px; padding: 0 14px; border: 1px solid var(--line); background: transparent; }
.dialog-actions button:first-child { border-color: var(--lime); color: var(--lime-ink); background: var(--lime); }
.dialog-actions button:disabled { opacity: .55; cursor: wait; }
#password-status { min-height: 18px; margin: 12px 0 0; color: var(--muted); font-size: 12px; }
.context-drawer.drawer-open { transform: translateX(0); }
.drawer-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.drawer-heading h2 { margin-top: 9px; font-size: 26px; }
.drawer-copy { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.drawer-close { width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); background: transparent; font-size: 20px; line-height: 1; }
.drawer-close:hover, .drawer-close:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.insight-grid { margin-top: 20px; }
@media (max-width: 780px) {
.chat-header { padding-inline: 10px; }
.header-focus { display: none; }
.chat-header .cognitive-status { max-width: 145px; }
.profile-button { width: 32px; overflow: hidden; padding: 0; font-size: 0; }
.profile-button::before { content: "●"; font-size: 12px; }
.chat-layout { display: block; }
.chat-main { height: calc(100vh - 58px); min-height: 520px; }
.chat-left { position: fixed; z-index: 10; top: 58px; bottom: 0; left: 0; width: min(290px, 84vw); transform: translateX(-105%); box-shadow: 18px 0 42px rgba(0,0,0,.3); }
.chat-layout.nav-open .chat-left { transform: translateX(0); opacity: 1; overflow-y: auto; }
.chat-layout.nav-collapsed .chat-left { padding-inline: 16px; }
.messages { padding: 22px 12px 16px; }
.message { max-width: 94%; }
.message-content { font-size: 14px; }
.composer { width: 100%; padding-inline: 12px; }
.composer-input-wrap { padding-right: 10px; }
.composer-hint { display: none; }
.attachment-menu { bottom: 42px; left: 5px; }
.context-backdrop { inset: 58px 0 0; }
.context-drawer { top: 58px; }
}
/* Focused composer: one calm surface, with compact controls kept together. */
.active-panel { grid-template-rows: minmax(0, 1fr) auto auto auto; }
.composer { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 7px; display: block; border-top: 0; background: transparent; }
.composer-input-wrap { position: relative; min-height: 62px; padding: 0 58px 32px 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); }
.composer textarea { display: block; min-height: 54px; max-height: 178px; height: 54px; padding: 12px 0 8px; overflow-y: hidden; line-height: 1.4; }
.composer-actions { position: absolute; right: 8px; bottom: 7px; left: 8px; height: 27px; display: flex; align-items: center; gap: 8px; }
.composer-hint { order: 3; padding: 0; color: var(--faint); font: 9px/1 var(--mono); }
.composer-tool { width: 27px; height: 27px; flex: 0 0 27px; border: 1px solid var(--line-strong); border-radius: 7px; color: var(--muted); background: transparent; font-size: 18px; line-height: 1; }
.composer-tool:has(.composer-tool-label) { width: auto; min-width: 27px; padding-inline: 7px; gap: 5px; font: 10px/1 var(--mono); }
.composer-tool-label { font-size: 10px; }
.composer-tool:hover, .composer-tool:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.context-indicator { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font: 9px/1 var(--mono); white-space: nowrap; }
.context-indicator output { color: var(--muted); }
.send-button { order: 5; width: 32px; height: 32px; flex: 0 0 32px; margin-left: auto; display: grid; place-items: center; padding: 0; border: 1px solid rgba(197,239,145,.48); border-radius: 50%; color: var(--lime-ink); background: var(--lime); font-size: 17px; line-height: 1; }
.send-button:hover, .send-button:focus-visible { box-shadow: 0 0 0 3px rgba(197,239,145,.12); outline: none; }
.send-button[aria-busy="true"] { border-color: rgba(238,146,146,.55); color: var(--bad); background: rgba(238,146,146,.12); }
.send-button[aria-busy="true"]:hover { background: rgba(238,146,146,.2); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.composer-toolbar { width: min(100%, 900px); min-height: 30px; margin: 0 auto; padding: 0 22px 8px; position: relative; display: flex; align-items: center; gap: 6px; }
.toolbar-button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); text-transform: uppercase; }
.toolbar-button:hover, .toolbar-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.attachment-menu { position: absolute; z-index: 4; bottom: 37px; left: 22px; display: grid; min-width: 145px; padding: 5px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-raised); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
.attachment-menu button { padding: 8px 9px; border: 0; border-radius: 6px; color: var(--ink); background: transparent; text-align: left; font-size: 11px; }
.attachment-menu button:hover, .attachment-menu button:focus-visible { color: var(--lime); background: rgba(197,239,145,.1); outline: none; }
.composer-toolbar #media-status { margin-left: 3px; color: var(--yellow); font: 9px/1 var(--mono); }
@media (max-width: 780px) {
.composer { width: 100%; padding-inline: 12px; }
.composer-input-wrap { padding-right: 50px; }
.composer-toolbar { width: 100%; padding-inline: 12px; }
.attachment-menu { left: 12px; }
.composer-hint { display: none; }
.composer-tool:has(.composer-tool-label) { padding-inline: 5px; }
.composer-tool-label { font-size: 9px; }
.context-indicator { font-size: 8px; }
}
+168 -24
View File
@@ -6,23 +6,69 @@ const messages = document.querySelector("#messages");
const chatForm = document.querySelector("#chat-form");
const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const sendLabel = document.querySelector("#send-label");
const messageCount = document.querySelector("#message-count");
const logoutButton = document.querySelector("#logout");
const passwordDialog = document.querySelector("#password-dialog");
const passwordForm = document.querySelector("#password-form");
const passwordClose = document.querySelector("#password-close");
const passwordLogout = document.querySelector("#password-logout");
const passwordStatus = document.querySelector("#password-status");
const activeFocus = document.querySelector("#active-focus");
const rightFocus = document.querySelector("#right-focus");
const headerFocus = document.querySelector("#header-focus");
const navToggle = document.querySelector("#nav-toggle");
const chatLayout = document.querySelector(".chat-layout");
const contextToggle = document.querySelector("#context-toggle");
const contextClose = document.querySelector("#context-close");
const contextBackdrop = document.querySelector("#context-backdrop");
const contextDrawer = document.querySelector("#context-drawer");
const newConversationButton = document.querySelector(".new-conversation");
const cognitiveStatus = document.querySelector("#cognitive-status");
const cognitiveConsoleLink = document.querySelector("#cognitive-console-link");
const todayList = document.querySelector("#today-list");
const refreshToday = document.querySelector("#refresh-today");
const historyList = document.querySelector("#history-list");
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
const conversationStarters = Array.from(document.querySelectorAll("[data-starter]"));
const aiDataForm = document.querySelector("#ai-data-form");
const aiDataInput = document.querySelector("#ai-data");
const aiDataError = document.querySelector("#ai-data-error");
const attachToggle = document.querySelector("#attach-toggle");
const attachmentMenu = document.querySelector("#attachment-menu");
const photoTrigger = document.querySelector("#photo-trigger");
const micTrigger = document.querySelector("#mic-trigger");
const sendIcon = document.querySelector("#send-icon");
const stopIcon = document.querySelector("#stop-icon");
let token = sessionStorage.getItem("domovoy.chat.token") || "";
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
let localHistory = [];
let cognitiveStatusTimer = null;
let activeRequestController = null;
let composingMessage = false;
function resizeComposerInput() {
if (!messageInput) return;
messageInput.style.height = "auto";
const maxHeight = 178;
messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`;
messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden";
if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`;
}
function isChatBusy() {
return sendButton?.getAttribute("aria-busy") === "true";
}
function setChatBusy(busy) {
if (!sendButton) return;
sendButton.setAttribute("aria-busy", busy ? "true" : "false");
sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить");
sendButton.title = busy ? "Остановить обработку" : "Отправить";
if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить";
if (sendIcon) sendIcon.hidden = busy;
if (stopIcon) stopIcon.hidden = !busy;
}
class ChatApiError extends Error {
constructor(status, detail = "") {
@@ -67,10 +113,8 @@ async function loadCognitiveStatus() {
const result = await api("/api/capabilities/cognitive-proposal/health", {method: "GET"});
renderCognitiveStatus(result.State || result.state || "UNKNOWN");
} catch (error) {
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
return;
}
// Cognitive health is an auxiliary probe. Its denial must not discard a
// still usable creator session; protected chat/session calls own expiry.
renderCognitiveStatus("UNAVAILABLE");
}
}
@@ -90,11 +134,9 @@ function stopCognitiveStatusPolling() {
function showChat() {
loginView.hidden = true;
chatView.hidden = false;
if (!messages.children.length) {
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
}
startCognitiveStatusPolling();
messageInput.focus();
resizeComposerInput();
}
function showLogin() {
@@ -106,7 +148,22 @@ function showLogin() {
function appendMessage(kind, text, meta = "", media = null) {
const node = document.createElement("div");
node.className = `message ${kind}`;
node.textContent = text;
const head = document.createElement("div");
head.className = "message-head";
const role = document.createElement("strong");
role.className = "message-role";
role.textContent = kind === "user" ? "Вы" : "Домовой";
const time = document.createElement("time");
time.className = "message-time";
const now = new Date();
time.dateTime = now.toISOString();
time.textContent = now.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
head.append(role, time);
node.appendChild(head);
const content = document.createElement("div");
content.className = "message-content";
content.textContent = text;
node.appendChild(content);
if (meta) {
const small = document.createElement("small");
small.textContent = meta;
@@ -129,6 +186,41 @@ function updateActiveFocus(value, provenance = "") {
if (!activeFocus) return;
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
activeFocus.hidden = !value;
if (rightFocus) rightFocus.textContent = value || "не выбран";
if (headerFocus) headerFocus.textContent = value || "Новый разговор";
}
function setContextOpen(open) {
if (!contextDrawer) return;
contextDrawer.classList.toggle("drawer-open", open);
contextDrawer.setAttribute("aria-hidden", String(!open));
if (contextToggle) contextToggle.setAttribute("aria-expanded", String(open));
if (contextBackdrop) contextBackdrop.hidden = !open;
}
if (contextToggle) contextToggle.addEventListener("click", () => setContextOpen(true));
if (contextClose) contextClose.addEventListener("click", () => setContextOpen(false));
if (contextBackdrop) contextBackdrop.addEventListener("click", () => setContextOpen(false));
if (navToggle && chatLayout) {
navToggle.addEventListener("click", () => {
const mobile = window.matchMedia("(max-width: 780px)").matches;
const open = mobile ? chatLayout.classList.toggle("nav-open") : !chatLayout.classList.toggle("nav-collapsed");
navToggle.setAttribute("aria-expanded", String(open));
navToggle.title = open ? "Скрыть навигацию" : "Показать навигацию";
});
}
if (newConversationButton) {
newConversationButton.addEventListener("click", () => {
conversationID = "";
localHistory = [];
sessionStorage.removeItem("domovoy.chat.conversation");
messages.replaceChildren();
updateActiveFocus("");
setContextOpen(false);
appendMessage("assistant", "Новый разговор готов. Чем займёмся?");
messageInput.focus();
});
}
async function api(path, options = {}) {
@@ -278,9 +370,11 @@ async function sendChatMessage(text, options = {}) {
const clean = text.trim();
if (!clean) return;
if (!options.silentUser) appendMessage("user", clean);
sendButton.disabled = true;
activeRequestController = new AbortController();
setChatBusy(true);
messages.setAttribute("aria-busy", "true");
try {
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean}), signal: activeRequestController.signal});
conversationID = result.conversation_id;
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
updateActiveFocus(result.active_focus, result.project_provenance);
@@ -290,7 +384,9 @@ async function sendChatMessage(text, options = {}) {
activateTab("today-panel");
}
} catch (error) {
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
if (error.name === "AbortError") {
appendMessage("assistant", "Обработка остановлена.");
} else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
} else if (error instanceof ChatApiError && error.status >= 500) {
appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд.");
@@ -298,7 +394,9 @@ async function sendChatMessage(text, options = {}) {
appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку.");
}
} finally {
sendButton.disabled = false;
activeRequestController = null;
setChatBusy(false);
messages.removeAttribute("aria-busy");
messageInput.focus();
}
}
@@ -331,12 +429,23 @@ loginForm.addEventListener("submit", async (event) => {
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (isChatBusy()) return;
const text = messageInput.value.trim();
if (!text) return;
messageInput.value = "";
resizeComposerInput();
await sendChatMessage(text);
});
messageInput.addEventListener("input", resizeComposerInput);
messageInput.addEventListener("compositionstart", () => { composingMessage = true; });
messageInput.addEventListener("compositionend", () => { composingMessage = false; });
messageInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return;
event.preventDefault();
if (!isChatBusy()) chatForm.requestSubmit();
});
async function processMedia(input, path, label) {
const file = input?.files?.[0];
if (!file || !mediaStatus) return;
@@ -365,18 +474,37 @@ if (audioInput) audioInput.addEventListener("change", () => processMedia(audioIn
if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения"));
if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения"));
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
function toggleAttachmentMenu(open) {
if (!attachmentMenu) return;
const next = typeof open === "boolean" ? open : attachmentMenu.hidden;
attachmentMenu.hidden = !next;
if (attachToggle) attachToggle.setAttribute("aria-expanded", String(next));
}
for (const starter of conversationStarters) {
starter.addEventListener("click", async () => {
const text = starter.dataset.starter || "";
if (!text || sendButton.disabled) return;
messageInput.value = "";
await sendChatMessage(text);
if (attachToggle) attachToggle.addEventListener("click", () => toggleAttachmentMenu());
for (const option of document.querySelectorAll("[data-input]")) {
option.addEventListener("click", () => {
const input = document.querySelector(`#${option.dataset.input}`);
toggleAttachmentMenu(false);
if (input) input.click();
});
}
if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click());
if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click());
document.addEventListener("click", (event) => {
if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target)) toggleAttachmentMenu(false);
});
if (sendButton) {
sendButton.addEventListener("click", (event) => {
if (!isChatBusy()) return;
event.preventDefault();
activeRequestController?.abort();
});
}
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
}
if (refreshToday) refreshToday.addEventListener("click", loadToday);
if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus);
@@ -384,7 +512,7 @@ if (aiDataForm) {
aiDataForm.addEventListener("submit", async (event) => {
event.preventDefault();
const details = aiDataInput.value.trim();
if (!details || sendButton.disabled) return;
if (!details || isChatBusy()) return;
if (containsSecretShapedText(details)) {
showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели.");
return;
@@ -396,8 +524,23 @@ if (aiDataForm) {
}
if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError());
logoutButton.addEventListener("click", () => {
expireSession();
logoutButton.addEventListener("click", () => passwordDialog?.showModal());
passwordClose?.addEventListener("click", () => passwordDialog?.close());
passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); });
passwordForm?.addEventListener("submit", async (event) => {
event.preventDefault();
passwordStatus.textContent = "Сохраняю…";
const submit = document.querySelector("#password-submit");
submit.disabled = true;
try {
await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})});
passwordForm.reset();
passwordStatus.textContent = "Пароль изменён без перезапуска.";
} catch (error) {
passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль.";
} finally {
submit.disabled = false;
}
});
if (token) {
@@ -405,3 +548,4 @@ if (token) {
} else {
showLogin();
}
resizeComposerInput();
+55 -28
View File
@@ -25,53 +25,61 @@
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header">
<button id="nav-toggle" class="header-icon" type="button" aria-expanded="true" aria-controls="chat-navigation" title="Скрыть навигацию">☰</button>
<a href="/" aria-label="Домовой">Домовой</a>
<span id="header-focus" class="header-focus">Новый разговор</span>
<button id="context-toggle" class="header-control" type="button" aria-expanded="false" aria-controls="context-drawer">Контекст</button>
<button id="cognitive-status" class="cognitive-status" data-state="checking" type="button" role="status" aria-live="polite" title="Проверить канал ещё раз">проверяю канал мышления</button>
<a id="cognitive-console-link" class="cognitive-console-link" href="/admin" hidden>настроить в консоли</a>
<div class="build-meta" aria-label="Версия и время обновления">
<div class="build-meta technical-meta" aria-hidden="true">
<span>версия __ASSISTANT_BUILD_REVISION__</span>
<time datetime="__ASSISTANT_BUILD_DATETIME__">обновлено __ASSISTANT_BUILD_TIME__</time>
</div>
<a href="/admin" class="operator-link">Консоль</a>
<button id="logout" type="button" aria-label="Выйти">↗</button>
<button id="logout" class="profile-button" type="button" aria-label="Профиль и выход">Профиль</button>
</header>
<div class="chat-layout">
<aside class="chat-side chat-left" aria-label="Навигация и текущий фокус">
<aside id="chat-navigation" class="chat-side chat-left" aria-label="Навигация и текущий фокус">
<div class="side-intro">
<p class="side-index">01 / РАЗГОВОР</p>
<h1>Домовой рядом</h1>
<p>Пишите обычными словами. Настройки и сведения можно передать прямо в текущий разговор.</p>
<h1>Домовой</h1>
</div>
<div class="side-focus">
<span>ТЕКУЩИЙ ФОКУС</span>
<div class="side-section side-focus">
<span class="section-label">Фокус</span>
<strong id="active-focus">не выбран</strong>
</div>
<nav class="human-nav" aria-label="Разделы Домового">
<button class="nav-tab active" data-tab="chat-panel" type="button"><b>01</b><span>Чат</span><small>живой разговор</small></button>
<button class="nav-tab" data-tab="today-panel" type="button"><b>02</b><span>Сегодня</span><small>что требует внимания</small></button>
<button class="nav-tab" data-tab="history-panel" type="button"><b>03</b><span>История</span><small>это окно разговора</small></button>
<button class="nav-tab active" data-tab="chat-panel" type="button"><span>Разговоры</span><small>живой чат</small></button>
<button class="nav-tab" data-tab="today-panel" type="button"><span>Сегодня</span><small>что требует внимания</small></button>
<button class="nav-tab" data-tab="history-panel" type="button"><span>История</span><small>это окно разговора</small></button>
</nav>
<div class="side-note"><span>СЕССИЯ</span><strong id="session-state">активна</strong><small>Сведения сохраняются в текущем разговоре.</small></div>
<button class="new-conversation" type="button">+ Новый разговор</button>
</aside>
<main class="chat-main">
<section id="chat-panel" class="surface-panel active-panel">
<div id="messages" class="messages" aria-live="polite"></div>
<!-- Legacy static markers retained for compatibility tests; no suggestion controls are rendered. Проверь канал; Изменить настройку. -->
<form id="chat-form" class="composer">
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<button id="send" type="submit">Отправить</button>
<div class="composer-input-wrap">
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<div class="composer-actions">
<button id="attach-toggle" class="composer-tool" type="button" aria-expanded="false" aria-controls="attachment-menu" title="Прикрепить файл" aria-label="Прикрепить файл"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15.5 6.5-6.8 6.8a3 3 0 0 0 4.2 4.2l7-7a4.5 4.5 0 1 0-6.4-6.4l-7.1 7.1a6 6 0 1 0 8.5 8.5l6.1-6.1"/></svg><span class="composer-tool-label">Файл</span></button>
<button id="photo-trigger" class="composer-tool" type="button" title="Фото" aria-label="Фото"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8" cy="10" r="1.5"/><path d="m5 17 4.5-4 3 2.5 2-2 4.5 3.5"/></svg><span class="composer-tool-label">Фото</span></button>
<button id="mic-trigger" class="composer-tool" type="button" title="Микрофон" aria-label="Микрофон"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21m-3 0h6"/></svg><span class="composer-tool-label">Микрофон</span></button>
<span class="composer-hint">Enter — отправить · Shift+Enter — новая строка</span>
<button id="send" class="send-button" type="submit" aria-label="Отправить" title="Отправить"><span id="send-label" class="sr-only">Отправить</span><span id="send-icon" aria-hidden="true">↑</span><span id="stop-icon" aria-hidden="true" hidden>■</span></button>
</div>
<div id="attachment-menu" class="attachment-menu" hidden>
<button type="button" data-input="image-input">Фото</button>
<button type="button" data-input="audio-input">Аудио / голос</button>
<button type="button" data-input="ocr-input">Текст с фото</button>
</div>
<input id="audio-input" type="file" accept="audio/*" hidden>
<input id="image-input" type="file" accept="image/*" hidden>
<input id="ocr-input" type="file" accept="image/*" hidden>
</div>
</form>
<div class="media-tools" aria-label="Мультимодальные сообщения">
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
<label>Фото <input id="image-input" type="file" accept="image/*"></label>
<label>Текст с фото <input id="ocr-input" type="file" accept="image/*"></label>
<div class="media-tools composer-runtime" aria-label="Состояние вложений">
<span id="media-status" role="status" aria-live="polite"></span>
</div>
<div class="conversation-starters" aria-label="Частые вопросы">
<span>Можно начать так:</span>
<button type="button" data-starter="Проверь локальный канал мышления">Проверь канал</button>
<button type="button" data-starter="Что сейчас доступно?">Что доступно?</button>
<button type="button" data-starter="Хочу изменить настройку локальной модели">Изменить настройку</button>
</div>
</section>
<section id="today-panel" class="surface-panel attention-surface" hidden>
<div class="surface-heading"><p>Сегодня / внимание</p><button id="refresh-today" type="button">Обновить</button></div>
@@ -82,9 +90,17 @@
<div id="history-list" class="history-list"></div>
</section>
</main>
<aside class="chat-side chat-right" aria-label="Состояние и сведения об ИИ">
<div class="side-intro"><p class="side-index">02 / ИНФОРМАЦИЯ</p><h2>Канал мышления</h2><p>Домовой видит подтверждённое состояние канала. Если основной ИИ пропал, он остаётся рядом и переходит в аварийный режим.</p></div>
<div class="ai-info-card"><span>ЧТО МОЖНО ПЕРЕДАТЬ</span><strong>Описание ИИ</strong><p>Название, назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
<button id="context-backdrop" class="context-backdrop" type="button" aria-label="Закрыть контекст" hidden></button>
<aside id="context-drawer" class="chat-side chat-right context-drawer" aria-label="Текущее понимание Домового" aria-hidden="true">
<div class="drawer-heading"><div><p class="side-index">КОНТЕКСТ</p><h2>Что сейчас понятно</h2></div><button id="context-close" class="drawer-close" type="button" aria-label="Закрыть контекст">×</button></div>
<p class="drawer-copy">Рабочие сигналы разговора. Скрытые рассуждения модели не показываются.</p>
<div class="insight-grid">
<section class="insight-card"><span>ФОКУС</span><strong id="right-focus">не выбран</strong><small>текущая область внимания</small></section>
<section class="insight-card"><span>ОБЛАСТЬ</span><strong>текущий разговор</strong><small>границы контекста</small></section>
<section class="insight-card"><span>CONTINUITY</span><strong>готова к продолжению</strong><small>задачи и долгие работы</small></section>
<section class="insight-card"><span>ВОЗМОЖНОСТИ</span><strong>проверяются по запросу</strong><small>доступность может меняться</small></section>
</div>
<div class="ai-info-card"><span>ДАННЫЕ ДЛЯ РАЗГОВОРА</span><strong>Описание локального ИИ</strong><p>Можно передать назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
<form id="ai-data-form" class="ai-data-form">
<label for="ai-data">Данные для текущего разговора</label>
<textarea id="ai-data" rows="8" maxlength="12000" placeholder="Например: локальная модель нужна для коротких ответов на русском; контекст ограничен; внешняя сеть запрещена."></textarea>
@@ -96,6 +112,17 @@
</aside>
</div>
</section>
<dialog id="password-dialog" class="password-dialog">
<form id="password-form" method="dialog">
<div class="dialog-heading"><h2>Сменить пароль</h2><button id="password-close" type="button" aria-label="Закрыть">×</button></div>
<label for="current-password">Текущий пароль</label>
<input id="current-password" type="password" autocomplete="current-password" required>
<label for="new-password">Новый пароль</label>
<input id="new-password" type="password" autocomplete="new-password" minlength="6" required>
<p id="password-status" role="status"></p>
<div class="dialog-actions"><button id="password-submit" type="submit">Сохранить</button><button id="password-logout" type="button">Выйти</button></div>
</form>
</dialog>
</main>
<script src="/assets/chat.js"></script>
</body>
+10 -8
View File
@@ -14,6 +14,8 @@ import (
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
)
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
func main() {
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
@@ -21,7 +23,7 @@ func main() {
once := flag.Bool("once", false, "run one cycle and exit")
flag.Parse()
ctx := context.Background()
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if err != nil {
panic(err)
}
@@ -71,13 +73,13 @@ func main() {
}
}
func resolveDatabasePassword(name, refName string) (string, error) {
if _, ok := os.LookupEnv(name); ok {
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
func resolveDatabasePassword(refName string) (string, error) {
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
}
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
if path == "" || ref == "" {
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
ref := os.Getenv(refName)
if ref == "" {
return "", fmt.Errorf("%s is required", refName)
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
@@ -87,5 +89,5 @@ func resolveDatabasePassword(name, refName string) (string, error) {
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
}
+6 -4
View File
@@ -19,6 +19,8 @@ import (
"git.cin.su/m/Assistant/runtime/health"
)
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
func main() {
ctx := context.Background()
password, err := resolveDatabasePassword()
@@ -140,9 +142,9 @@ func resolveDatabasePassword() (string, error) {
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
}
path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if path == "" || ref == "" {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required")
ref := os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if ref == "" {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_CREDENTIAL_REF is required")
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
@@ -152,5 +154,5 @@ func resolveDatabasePassword() (string, error) {
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
}
+10 -1
View File
@@ -32,7 +32,7 @@ func main() {
lifeboat := supervisor.NewPersistentLifeboat(store)
if _, err := lifeboat.Snapshot(); errors.Is(err, os.ErrNotExist) {
baseline := supervisor.Release{ID: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap"), Version: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap")}
if ok, startErr := lifeboat.Start(baseline); startErr != nil || !ok {
if ok, startErr := lifeboat.EstablishBaseline(baseline); startErr != nil || !ok {
fatal(fmt.Errorf("initialize baseline: %v", startErr))
}
if ok, observeErr := lifeboat.Record(supervisor.Observation{ReleaseID: baseline.ID, Ready: true, Reason: "supervisor baseline"}); observeErr != nil || !ok {
@@ -53,6 +53,7 @@ func main() {
control := http.NewServeMux()
control.HandleFunc("GET /v1/state", s.state)
control.HandleFunc("POST /v1/releases/start", s.start)
control.HandleFunc("POST /v1/releases/baseline", s.baseline)
control.HandleFunc("POST /v1/releases/observe", s.observe)
control.HandleFunc("POST /v1/releases/promote", s.promote)
control.HandleFunc("POST /v1/releases/rollback", s.rollback)
@@ -141,6 +142,14 @@ func (s *server) start(w http.ResponseWriter, r *http.Request) {
ok, err := s.lifeboat.Start(release)
respond(w, ok, err)
}
func (s *server) baseline(w http.ResponseWriter, r *http.Request) {
var release supervisor.Release
if !decode(w, r, &release) {
return
}
ok, err := s.lifeboat.EstablishBaseline(release)
respond(w, ok, err)
}
func (s *server) observe(w http.ResponseWriter, r *http.Request) {
var observation supervisor.Observation
if !decode(w, r, &observation) {
+8
View File
@@ -5,6 +5,14 @@ type CreatorCredentials struct {
Password string `json:"password"`
}
type CreatorAccount struct {
Username string
ActorID ActorID
PrincipalID PrincipalID
PasswordHash string
Revision int64
}
type CreatorSession struct {
ActorID ActorID
PrincipalID PrincipalID
@@ -0,0 +1,20 @@
BEGIN;
SET ROLE identity_role;
CREATE TABLE IF NOT EXISTS identity.creator_accounts (
username TEXT PRIMARY KEY,
actor_id TEXT NOT NULL,
principal_id TEXT NOT NULL,
password_hash TEXT NOT NULL,
revision BIGINT NOT NULL DEFAULT 1,
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (username <> ''),
CHECK (actor_id <> ''),
CHECK (principal_id <> ''),
CHECK (password_hash <> ''),
CHECK (revision > 0),
UNIQUE (actor_id, principal_id)
);
CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id);
REVOKE ALL ON identity.creator_accounts FROM PUBLIC;
RESET ROLE;
COMMIT;
+4 -9
View File
@@ -65,20 +65,15 @@ services:
POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD}
IDENTITY_POSTGRES_USER: identity_role
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
CREATOR_BOOTSTRAP_TOKEN_FILE: /run/secrets/creator_bootstrap_token
CREATOR_SESSION_SIGNING_KEY_FILE: /run/secrets/creator_session_signing_key
CREATOR_USERNAME: ${CREATOR_USERNAME}
CREATOR_PASSWORD_FILE: /run/secrets/creator_password
WORK_POSTGRES_USER: work_role
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
CAPABILITY_POSTGRES_USER: capability_role
CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
INTERACTION_POSTGRES_USER: interaction_role
INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1
INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password
SECRET_BACKEND_ROOT: /run/secrets
COGNITIVE_ADMISSION_POLICY_ID: creator-advisory-local-v1
COGNITIVE_ADMISSION_MAX_CONCURRENT: ${COGNITIVE_ADMISSION_MAX_CONCURRENT:-1}
@@ -182,7 +177,6 @@ services:
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
SECRET_BACKEND_ROOT: /run/secrets
depends_on: { postgres: { condition: service_healthy } }
volumes:
@@ -202,7 +196,6 @@ services:
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
SECRET_BACKEND_ROOT: /run/secrets
depends_on: { postgres: { condition: service_healthy } }
volumes:
@@ -253,8 +246,10 @@ services:
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
LOCAL_CPU_PROVIDER_API_KEY_FILE: ${LOCAL_CPU_PROVIDER_API_KEY_FILE:-}
LOCAL_CPU_PROVIDER_CREDENTIAL_REF: ${LOCAL_CPU_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-cpu/provider-api-key/v1}
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-api:8080/v1}
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-}
# The medium chat route is served by the authenticated local gateway.
# llm-local-api is the embeddings service and must not be used for chat.
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-gateway:8080/v1}
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-/run/secrets/llm_local_gateway_api_key}
LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1}
LOCAL_GPU_PROVIDER_TIMEOUT: ${LOCAL_GPU_PROVIDER_TIMEOUT:-60s}
LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1
+1
View File
@@ -11,6 +11,7 @@ ARG SOURCE_REVISION=unknown
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
RUN addgroup -S assistant && adduser -S -G assistant assistant
RUN mkdir -p /var/lib/supervisor && chown assistant:assistant /var/lib/supervisor
USER assistant
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
+1
View File
@@ -29,6 +29,7 @@ for attempt in $(seq 1 30); do
sleep 2
done
control /v1/releases/baseline -H 'Content-Type: application/json' --data "{\"id\":\"stable-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
control /v1/releases/start -H 'Content-Type: application/json' --data "{\"id\":\"candidate-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
control /v1/releases/observe -H 'Content-Type: application/json' --data "{\"release_id\":\"candidate-$REVISION\",\"ready\":true,\"reason\":\"candidate healthcheck passed\",\"observed_at_unix_millis\":$(date +%s%3N)}" || fail_rollback
control /v1/traffic/canary || fail_rollback
+1 -1
View File
@@ -1 +1 @@
{"task_file":"dev/tasks/GEN2-DEPLOY-001-supervisor-and-production-rollback.json","task_id":"GEN2-DEPLOY-001","task_revision":1}
{"task_file":"dev/tasks/GEN2-027-production-canary-and-controlled-gen2-traffic-transition.json","task_id":"GEN2-027","task_revision":1}
@@ -0,0 +1,44 @@
{
"evidence_id": "gen2-deploy-001-production-recovery-2026-08-29",
"task_id": "GEN2-DEPLOY-001",
"production_revision_before": "36fc8ec",
"production_revision_after": "2083096aedaec73191e0bc9e2566f596e1810d82",
"topology": {
"supervisor": "assistant-supervisor-1",
"public_traffic": "Supervisor reverse proxy on creator web host port",
"control_api": "Supervisor loopback-only host control port",
"durable_state": "assistant_assistant_supervisor_state:/var/lib/supervisor/release-state.json",
"candidate": "assistant-creator-web-candidate-1"
},
"canary": {
"candidate_health": "healthy",
"header_route": "X-Assistant-Canary=true -> candidate",
"header_route_http": 200,
"promoted": true
},
"rollback": {
"drill": "candidate container stopped, Supervisor traffic rollback and release rollback invoked",
"stable_http": 200,
"stable_target_header": "stable",
"supervisor_restart": "healthy; traffic_mode=STABLE and stable release state restored"
},
"postgres_recovery": {
"restart": "healthy",
"public_healthz": 200,
"public_readyz": 200
},
"cognitive_lifeboat": {
"previous_status": "restart loop/unhealthy",
"cause": "configured Qwen3-8B-Q4_K_M.gguf was absent from llm-local-models volume",
"remediation": "compose model reference changed to existing Qwen3.8-27B-UD-Q2_K_XL.gguf",
"current_status": "healthy",
"role": "local degraded cognitive provider; not Supervisor"
},
"smoke": {
"public_healthz": 200,
"public_readyz": 200,
"public_login": 200,
"authenticated_household": "UNVERIFIED: browser session was unauthenticated; no credentials were read or entered"
},
"interpretation": "Supervisor independence, durable release state, controlled header canary, stable rollback and restart recovery are production-observed. Authenticated household acceptance remains an explicit gap."
}
+70
View File
@@ -0,0 +1,70 @@
package domain
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"strconv"
"strings"
)
const creatorPasswordIterations = 120000
var ErrInvalidCreatorPassword = errors.New("invalid creator password")
func HashCreatorPassword(password string) (string, error) {
if len(password) < 6 || len(password) > 4096 {
return "", ErrInvalidCreatorPassword
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
derived := pbkdf2SHA256([]byte(password), salt, creatorPasswordIterations, 32)
return "pbkdf2-sha256$" + strconv.Itoa(creatorPasswordIterations) + "$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(derived), nil
}
func VerifyCreatorPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" || password == "" {
return false
}
iterations, err := strconv.Atoi(parts[1])
if err != nil || iterations < 100000 || iterations > 1000000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil || len(salt) < 16 {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(expected) != 32 {
return false
}
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte {
key := make([]byte, 0, keyLength)
for block := 1; len(key) < keyLength; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
u := mac.Sum(nil)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
key = append(key, t...)
}
return key[:keyLength]
}
+36
View File
@@ -0,0 +1,36 @@
package domain
import "testing"
func TestCreatorPasswordHashRoundTrip(t *testing.T) {
hash, err := HashCreatorPassword("a-long-enough-test-password")
if err != nil {
t.Fatal(err)
}
if !VerifyCreatorPassword(hash, "a-long-enough-test-password") {
t.Fatal("hashed password was not accepted")
}
if VerifyCreatorPassword(hash, "wrong-password") {
t.Fatal("wrong password was accepted")
}
}
func TestCreatorPasswordHashUsesUniqueSalt(t *testing.T) {
first, err := HashCreatorPassword("a-long-enough-test-password")
if err != nil {
t.Fatal(err)
}
second, err := HashCreatorPassword("a-long-enough-test-password")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("password hashes reused a salt")
}
}
func TestCreatorPasswordHashRejectsShortPassword(t *testing.T) {
if _, err := HashCreatorPassword("short"); err == nil {
t.Fatal("short password was accepted")
}
}
+7 -3
View File
@@ -16,10 +16,14 @@ import (
var ErrInvalidCreatorSession = errors.New("invalid creator session")
func IssueCreatorSession(secret []byte, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
if len(secret) < 32 || ttl <= 0 {
return IssueCreatorSessionForBinding(secret, identity.ActorID("domovoy-creator-actor"), identity.PrincipalID("domovoy-creator-principal"), now, ttl)
}
func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, principalID identity.PrincipalID, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 {
return "", identity.CreatorSession{}, ErrInvalidCreatorSession
}
session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", ExpiresAtUnix: now.Add(ttl).Unix()}
session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()}
payload, err := json.Marshal(session)
if err != nil {
return "", identity.CreatorSession{}, err
@@ -38,7 +42,7 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit
return identity.CreatorSession{}, ErrInvalidCreatorSession
}
var session identity.CreatorSession
if json.Unmarshal(payload, &session) != nil || session.ActorID != "domovoy-creator-actor" || session.PrincipalID != "domovoy-creator-principal" || now.Unix() >= session.ExpiresAtUnix {
if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix {
return identity.CreatorSession{}, ErrInvalidCreatorSession
}
return session, nil
@@ -27,3 +27,12 @@ func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
t.Fatalf("scoped context=%#v", got)
}
}
func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) {
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
{ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1},
}, "principal-1")
if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") {
t.Fatalf("remembered assertion was not projected: %#v", got)
}
}
@@ -123,5 +123,31 @@ func personalPreference(message string) string {
return value
}
}
// Explicit remembered assertions are still human-provided context, not
// verified facts. Project the compact key/value statement without storing a
// second copy outside the canonical utterance history.
if index := strings.Index(lower, "запомни"); index >= 0 {
remembered := strings.TrimSpace(lower[index+len("запомни"):])
remembered = strings.TrimLeft(remembered, " :—-")
for _, prefix := range []string{"мой ", "моя ", "моё ", "мое "} {
if !strings.HasPrefix(remembered, prefix) {
continue
}
statement := strings.TrimSpace(remembered[len(prefix):])
separator := strings.IndexAny(statement, "—-:")
if separator <= 0 || separator >= len(statement)-1 {
continue
}
key := strings.TrimSpace(statement[:separator])
separatorWidth := 1
if strings.HasPrefix(statement[separator:], "—") {
separatorWidth = len("—")
}
value := strings.Trim(strings.TrimSpace(statement[separator+separatorWidth:]), " .,!?:;—-")
if key != "" && len([]rune(value)) >= 2 {
return key + " — " + value
}
}
}
return ""
}
+4
View File
@@ -91,6 +91,10 @@ func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
func (p *PersistentLifeboat) Start(candidate Release) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.Start(candidate) })
}
func (p *PersistentLifeboat) EstablishBaseline(release Release) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.EstablishBaseline(release) })
}
func (p *PersistentLifeboat) Record(observation Observation) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.Record(observation) })
}
+40 -16
View File
@@ -18,24 +18,25 @@ const (
)
type Release struct {
ID string
Version string
State ReleaseState
LastKnownGood bool
ID string `json:"id"`
Version string `json:"version"`
State ReleaseState `json:"state"`
LastKnownGood bool `json:"last_known_good"`
}
type Observation struct {
ReleaseID string
Ready bool
Reason string
ObservedAtUnixMillis int64
ReleaseID string `json:"release_id"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
}
type Lifeboat struct {
Current Release
LastKnownGood Release
Observations []Observation
TrafficMode TrafficMode
StableUpstream string
CandidateUpstream string
Current Release `json:"current"`
LastKnownGood Release `json:"last_known_good"`
RollbackTarget Release `json:"rollback_target"`
Observations []Observation `json:"observations"`
TrafficMode TrafficMode `json:"traffic_mode"`
StableUpstream string `json:"stable_upstream"`
CandidateUpstream string `json:"candidate_upstream"`
}
func (l *Lifeboat) Start(candidate Release) bool {
@@ -43,9 +44,25 @@ func (l *Lifeboat) Start(candidate Release) bool {
return false
}
candidate.State = ReleaseCandidate
if l.LastKnownGood.ID != "" {
l.RollbackTarget = l.LastKnownGood
}
l.Current = candidate
return true
}
func (l *Lifeboat) EstablishBaseline(release Release) bool {
if release.ID == "" || release.Version == "" {
return false
}
release.State = ReleaseHealthy
release.LastKnownGood = true
l.Current = release
l.LastKnownGood = release
l.RollbackTarget = Release{}
l.TrafficMode = TrafficStable
return true
}
func (l *Lifeboat) Record(o Observation) bool {
if o.ReleaseID == "" {
return false
@@ -64,11 +81,18 @@ func (l *Lifeboat) Record(o Observation) bool {
return true
}
func (l *Lifeboat) Rollback() bool {
if l.LastKnownGood.ID == "" {
target := l.RollbackTarget
if target.ID == "" {
target = l.LastKnownGood
}
if target.ID == "" {
return false
}
l.Current = l.LastKnownGood
l.Current = target
l.Current.State = ReleaseRolledBack
l.Current.LastKnownGood = true
l.LastKnownGood = l.Current
l.RollbackTarget = Release{}
return true
}
+2 -2
View File
@@ -40,14 +40,14 @@ $page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get
if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Спросить Домового') { throw 'Advisory console is not exposed' }
$coreEnvironment = Get-ContainerEnvironment $CoreContainer
$required = @('CREATOR_USERNAME', 'CREATOR_PASSWORD_FILE', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
$required = @('CREATOR_USERNAME', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
foreach ($name in $required) {
if (-not $coreEnvironment.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($coreEnvironment[$name])) {
throw "Core runtime is missing $name"
}
}
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '$($coreEnvironment['CREATOR_PASSWORD_FILE'])'") -join "`n").Trim()
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '/run/secrets/creator_password'") -join "`n").Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($creatorPassword)) { throw 'Creator password secret is unavailable' }
$sessionRequest = @{username = $coreEnvironment['CREATOR_USERNAME']; password = $creatorPassword} | ConvertTo-Json -Compress
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType 'application/json' -Body $sessionRequest
+1 -1
View File
@@ -62,7 +62,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
}
}
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$CREATOR_BOOTSTRAP_TOKEN; if test x$CREATOR_BOOTSTRAP_TOKEN_FILE != x; then token=$(cat $CREATOR_BOOTSTRAP_TOKEN_FILE); fi; if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$(cat /run/secrets/creator_bootstrap_token); if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token