feat(emma): add conversation history and release tooling
This commit is contained in:
parent
2a48141b99
commit
aee859b3ba
100
docs/runbooks/opax-live-deploy.md
Normal file
100
docs/runbooks/opax-live-deploy.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# OPAX Live Deployment Runbook
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Use these scripts to deploy the two production components required for the
|
||||||
|
Emma/OPAX experience:
|
||||||
|
|
||||||
|
- OPAX MCP: the conversational MCP backend and `run_emma` tool service.
|
||||||
|
- OPAX Web: the live web application served at `https://opax.vauco.no`.
|
||||||
|
|
||||||
|
## Fixed Production Targets
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Google Cloud project | `propane-will-491900-m5` |
|
||||||
|
| Region | `us-central1` |
|
||||||
|
| Artifact Registry repository | `us-central1-docker.pkg.dev/propane-will-491900-m5/osvauco-repo` |
|
||||||
|
| MCP Cloud Run service | `opax-mcp` |
|
||||||
|
| OPAX Web Cloud Run service | `opax-web` |
|
||||||
|
| Live operator URL | `https://opax.vauco.no` |
|
||||||
|
|
||||||
|
## Normal Release Sequence
|
||||||
|
|
||||||
|
Run commands from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy-mcp.sh
|
||||||
|
./scripts/deploy-opax-web.sh
|
||||||
|
./scripts/check-live-services.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The scripts build an image, resolve its immutable digest, and deploy that digest
|
||||||
|
to the existing Cloud Run service.
|
||||||
|
|
||||||
|
## MCP Deployment Behavior
|
||||||
|
|
||||||
|
The existing `cloudbuild.deploy.yaml` workflow builds and pushes an MCP image.
|
||||||
|
Its internal Cloud Build deploy step can fail because the Cloud Build service
|
||||||
|
account is blocked by VPC Service Controls.
|
||||||
|
|
||||||
|
`deploy-mcp.sh` handles that condition by resolving the image that was pushed
|
||||||
|
during the build and then deploying its immutable digest directly through the
|
||||||
|
authenticated local `gcloud` session.
|
||||||
|
|
||||||
|
A Cloud Build failure does not automatically mean the image build failed. The
|
||||||
|
script stops if it cannot resolve an immutable pushed image digest.
|
||||||
|
|
||||||
|
## Live Validation
|
||||||
|
|
||||||
|
After deployment:
|
||||||
|
|
||||||
|
1. Open `https://opax.vauco.no`.
|
||||||
|
2. Hard-refresh the browser with `Ctrl+Shift+R`.
|
||||||
|
3. Log in normally.
|
||||||
|
4. Confirm that the new Emma workspace UI is visible.
|
||||||
|
5. In a new conversation, send:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Jeg heter Chris.
|
||||||
|
```
|
||||||
|
|
||||||
|
6. In the same conversation, send:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hva heter jeg?
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Confirm that Emma uses the previous message as conversation history.
|
||||||
|
8. Start a second conversation and ask:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hva heter jeg?
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Confirm that the second conversation does not inherit context from the first
|
||||||
|
conversation.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Use `us-central1` only.
|
||||||
|
- Never deploy to `europe-west1`.
|
||||||
|
- Never deploy a service named `opax`.
|
||||||
|
- Deploy only the existing `opax-mcp` and `opax-web` services.
|
||||||
|
- Final Cloud Run deployment must use an immutable image digest.
|
||||||
|
- Never use a mutable tag for the final deploy.
|
||||||
|
- Never print or place secret values in scripts, logs, documentation, or Git.
|
||||||
|
- Do not deploy from Gemini without explicit human approval.
|
||||||
|
- Do not change IAM, service accounts, secrets, VPC settings, DNS, OAuth, or
|
||||||
|
Cloud Run networking as part of a normal application release.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Use one of these existing rollback methods:
|
||||||
|
|
||||||
|
1. In Cloud Run, route traffic back to the prior ready revision.
|
||||||
|
2. Re-run the relevant deployment script after replacing the image digest with a
|
||||||
|
previously known good immutable digest.
|
||||||
|
|
||||||
|
Use `./scripts/check-live-services.sh` to record the currently active revisions
|
||||||
|
and images before a release.
|
||||||
|
|
@ -47,4 +47,5 @@ class CanonicalEmma:
|
||||||
self._model,
|
self._model,
|
||||||
prompt,
|
prompt,
|
||||||
composed_system_prompt,
|
composed_system_prompt,
|
||||||
|
history=history or [],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
163
opax-mcp/manifests/tools/run_emma.md
Normal file
163
opax-mcp/manifests/tools/run_emma.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# run_emma / emma.chat Component Manifest
|
||||||
|
|
||||||
|
**Status:** Draft<br>
|
||||||
|
**Runtime effect:** None<br>
|
||||||
|
**Governing standard:** [`INCU Master Constitution`](../../../docs/INCU_Master_Constitution.md)<br>
|
||||||
|
**Template:** [`INCU Component Manifest Template`](../../../docs/templates/component_manifest.md)<br>
|
||||||
|
**Review date:** 2026-10-18<br>
|
||||||
|
|
||||||
|
> This is a documentation-only draft manifest for an existing runtime
|
||||||
|
> capability. It does not activate, disable, expand, or otherwise modify the
|
||||||
|
> `run_emma` tool, the `emma.chat` capability, Emma's prompt, Emma's authority,
|
||||||
|
> A2H2A behavior, MCP behavior, or any external-system permission.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Component ID: emma.chat
|
||||||
|
- Component name: run_emma
|
||||||
|
- Class: Orchestrator
|
||||||
|
- Version: 0.1.0-draft
|
||||||
|
- Owner: Platform Engineering
|
||||||
|
- Status: draft
|
||||||
|
- Review date: 2026-10-18
|
||||||
|
|
||||||
|
Current runtime capability status:
|
||||||
|
|
||||||
|
ACTIVE in the existing capability registry.
|
||||||
|
|
||||||
|
Current manifest governance status:
|
||||||
|
|
||||||
|
draft.
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
The manifest is documentation-only. No manifest validator or runtime
|
||||||
|
manifest-enforcement mechanism exists yet.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Intended outcome: Provide conversational reasoning through the canonical Emma
|
||||||
|
runtime for a user-supplied prompt, while retaining the canonical system
|
||||||
|
prompt and appending only the read-only capability-status system context.
|
||||||
|
- Value to system: Provides a single canonical entry point for conversational
|
||||||
|
AI reasoning, decoupled from specific models or backends, and supplies
|
||||||
|
governance documentation for future manifest-driven orchestration.
|
||||||
|
- Explicit non-goals:
|
||||||
|
- No tool execution.
|
||||||
|
- No repository or file access.
|
||||||
|
- No deployment or infrastructure action.
|
||||||
|
- No external-system action, integration operation, or state-changing service
|
||||||
|
call. The existing model-inference request remains part of the current
|
||||||
|
canonical conversational runtime path only.
|
||||||
|
- No memory or document retrieval.
|
||||||
|
- No ticket creation, ticket approval, or A2H2A execution.
|
||||||
|
- No write, deletion, permission, scheduling, or communication action.
|
||||||
|
- No delegation to additional agents.
|
||||||
|
- No independent completion claim beyond the returned conversational response.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Permitted tasks:
|
||||||
|
- Accept a user-provided prompt through the existing canonical run_emma path.
|
||||||
|
- Compose the canonical Emma system prompt with read-only capability context.
|
||||||
|
- Return the model's conversational response.
|
||||||
|
- Prohibited tasks: Any task corresponding to a planned or forbidden
|
||||||
|
capability. This manifest does not activate any capability.
|
||||||
|
- Supported systems/environments: Existing opax-mcp runtime path.
|
||||||
|
- Time/cost/retry limits: Not currently declared in the runtime capability
|
||||||
|
registry. No additional retry, time, or cost authority is granted by this
|
||||||
|
manifest.
|
||||||
|
|
||||||
|
## Authority
|
||||||
|
|
||||||
|
- Read permissions: User-provided prompt and existing canonical
|
||||||
|
prompt/capability-context inputs within the current request path only.
|
||||||
|
- Write permissions: None.
|
||||||
|
- Approval requirement: No external or state-changing action is within scope.
|
||||||
|
Any future external or irreversible action requires a separate in-scope
|
||||||
|
manifest, exact explicit human approval, and platform authorization.
|
||||||
|
- Delegation rules: No delegation to subordinate agents, tools, connectors,
|
||||||
|
workflows, or operators is implemented or authorized.
|
||||||
|
- Revocation method: Disable or remove the existing runtime capability through
|
||||||
|
the authoritative runtime capability/tool configuration and platform
|
||||||
|
controls; this draft document alone does not revoke runtime access.
|
||||||
|
|
||||||
|
## Inputs and Outputs
|
||||||
|
|
||||||
|
- Required inputs: A user prompt supplied through the canonical run_emma
|
||||||
|
request path.
|
||||||
|
- Optional inputs: None declared for this manifest.
|
||||||
|
- Outputs: The existing raw conversational response returned by the canonical
|
||||||
|
Emma adapter/runtime path.
|
||||||
|
- Source of truth: Current capability registry for capability status; current
|
||||||
|
source code and tests for runtime contract; platform runtime for actual
|
||||||
|
service state.
|
||||||
|
- Evidence format: Existing unit-test output, capability-registry state,
|
||||||
|
adapter call contract, and runtime health/response evidence where separately
|
||||||
|
verified.
|
||||||
|
|
||||||
|
## INCU Mandate
|
||||||
|
|
||||||
|
- Outcome statement format: Return a bounded conversational response to a
|
||||||
|
user-supplied prompt without claiming unavailable authority.
|
||||||
|
- Startability rule: Begin only with the current user-provided prompt and the
|
||||||
|
existing read-only runtime path.
|
||||||
|
- Applicable levers: Interest, Novelty, Challenge, Urgency, Purpose — future
|
||||||
|
governance reference only; no runtime behavior change is introduced by this
|
||||||
|
manifest.
|
||||||
|
- Boundedness rule: One user prompt, current canonical runtime path, no
|
||||||
|
external-system action, no tool delegation, no state mutation, and no
|
||||||
|
authority expansion.
|
||||||
|
- Restart artifact: Not currently implemented for single-request conversational
|
||||||
|
handling.
|
||||||
|
- Blocker behavior: If a request requires a missing capability, tool, external
|
||||||
|
action, write, deployment, repository operation, memory/document access, or
|
||||||
|
unapproved delegation, state that it is unavailable and require a separate
|
||||||
|
approved manifest and explicit human authorization before any future
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Safety and Governance
|
||||||
|
|
||||||
|
- Data classification: User-provided request content; classification and
|
||||||
|
retention are governed by existing platform policy and are not changed by
|
||||||
|
this manifest.
|
||||||
|
- Security constraints: Secrets must not be intentionally included in prompts
|
||||||
|
or outputs. This draft manifest does not implement secret detection or
|
||||||
|
redaction. No tool execution or external-system action is authorized, and
|
||||||
|
existing platform authentication and authorization remain controlling.
|
||||||
|
- Privacy constraints: Do not claim retention, deletion, or consent guarantees
|
||||||
|
not established by current implementation.
|
||||||
|
- Stop conditions: Missing capability, external or state-changing request,
|
||||||
|
request outside current conversational scope, missing approval for a future
|
||||||
|
consequential action, or evidence conflict.
|
||||||
|
- Escalation path: Accountable human owner / Platform Engineering.
|
||||||
|
- Audit events: No new INCU audit event is created by this draft manifest.
|
||||||
|
Existing runtime and A2H2A audit behavior remain unchanged.
|
||||||
|
|
||||||
|
## A2HA Contract
|
||||||
|
|
||||||
|
- Ticket fields read: None.
|
||||||
|
- Ticket fields written: None.
|
||||||
|
- Allowed state transitions: None.
|
||||||
|
- Required evidence before transition: Not applicable, because this component
|
||||||
|
has no A2HA write authority.
|
||||||
|
- Comment/update policy: No A2HA comment or update authority is granted.
|
||||||
|
|
||||||
|
## Evaluation
|
||||||
|
|
||||||
|
- Acceptance tests:
|
||||||
|
- Capability registry retains emma.chat as ACTIVE.
|
||||||
|
- Capability bridge remains deterministic and read-only.
|
||||||
|
- Canonical Emma adapter retains the original user prompt.
|
||||||
|
- Capability context is appended only to the system prompt.
|
||||||
|
- No tool, connector, external request, repository, deployment, memory,
|
||||||
|
document-retrieval, or state-changing capability is activated.
|
||||||
|
- Reliability metrics: Existing test success/failure and runtime health
|
||||||
|
evidence; no new metrics system is introduced.
|
||||||
|
- Safety metrics:
|
||||||
|
- Unauthorized-write rate must remain zero.
|
||||||
|
- Unauthorized tool/delegation rate must remain zero.
|
||||||
|
- Review/rollback procedure: Review this draft on or before 2026-10-18. To
|
||||||
|
pause or change runtime behavior, use the existing capability/tool
|
||||||
|
configuration and human-approved change process. This draft document has no
|
||||||
|
runtime rollback effect.
|
||||||
|
|
@ -726,13 +726,19 @@ async def trigger_build(p: dict) -> dict:
|
||||||
# Ollama helpers — direkte mot emma-gpu-vm
|
# Ollama helpers — direkte mot emma-gpu-vm
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _ollama_chat(model: str, prompt: str, system: str = "") -> dict:
|
async def _ollama_chat(model: str, prompt: str, system: str = "", history: Optional[List[Dict[str, str]]] = None) -> dict:
|
||||||
# ... (beholdt uendret)
|
# ... (beholdt uendret)
|
||||||
messages = []
|
messages = []
|
||||||
if system:
|
if system:
|
||||||
messages.append({"role": "system", "content": system})
|
messages.append({"role": "system", "content": system})
|
||||||
|
if history:
|
||||||
|
messages.extend(history)
|
||||||
messages.append({"role": "user", "content": prompt})
|
messages.append({"role": "user", "content": prompt})
|
||||||
payload = { "model": model, "messages": messages, "stream": False }
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120) as c:
|
async with httpx.AsyncClient(timeout=120) as c:
|
||||||
r = await c.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload)
|
r = await c.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload)
|
||||||
|
|
@ -873,12 +879,38 @@ async def tui_command(p): return await _agent_post("/tui-comman
|
||||||
async def run_jason(p):
|
async def run_jason(p):
|
||||||
"""Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk."""
|
"""Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk."""
|
||||||
return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
|
return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
|
||||||
|
def _normalize_emma_history(value):
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for entry in value:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
role = entry.get("role")
|
||||||
|
content = entry.get("content")
|
||||||
|
|
||||||
|
if role not in {"user", "assistant"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(content, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = content.strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized.append({"role": role, "content": content})
|
||||||
|
|
||||||
|
return normalized[-40:]
|
||||||
|
|
||||||
async def run_emma(p: dict) -> dict:
|
async def run_emma(p: dict) -> dict:
|
||||||
"""Kaller den kanoniske Emma-agenten med en prompt."""
|
"""Kaller den kanoniske Emma-agenten med en prompt."""
|
||||||
capability_system_context = build_capability_system_context()
|
capability_system_context = build_capability_system_context()
|
||||||
return await canonical_emma.run(
|
return await canonical_emma.run(
|
||||||
prompt=p.get("prompt", p.get("message", "")),
|
prompt=p.get("prompt", p.get("message", "")),
|
||||||
history=[],
|
history=_normalize_emma_history(p.get("history")),
|
||||||
system_context=capability_system_context,
|
system_context=capability_system_context,
|
||||||
)
|
)
|
||||||
async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent..."))
|
async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent..."))
|
||||||
|
|
@ -1164,7 +1196,7 @@ TOOLS = {
|
||||||
),
|
),
|
||||||
# AI Agents
|
# AI Agents
|
||||||
"run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}),
|
"run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}),
|
||||||
"run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type":"object","properties":{"prompt":{"type":"string"}}}),
|
"run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type": "object", "properties": {"prompt": {"type": "string"}, "history": {"type": "array", "items": {"type": "object", "properties": {"role": {"type": "string", "enum": ["user", "assistant"]}, "content": {"type": "string"}}}}}}),
|
||||||
|
|
||||||
# Local models — read-only discovery
|
# Local models — read-only discovery
|
||||||
"list_emma_models": (
|
"list_emma_models": (
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,24 @@ router.get('/approvals', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizeHistory(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry &&
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
(entry.role === 'user' || entry.role === 'assistant') &&
|
||||||
|
typeof entry.content === 'string' &&
|
||||||
|
entry.content.trim().length > 0
|
||||||
|
)
|
||||||
|
.slice(-40)
|
||||||
|
.map(({ role, content }) => ({ role, content: content.trim() }));
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/emma/chat', async (req, res) => {
|
router.post('/emma/chat', async (req, res) => {
|
||||||
const { message, session_id, ticket_number } = req.body;
|
const { message, session_id, ticket_number, history } = req.body;
|
||||||
|
|
||||||
if (!message || typeof message !== 'string' || message.length > 4096) {
|
if (!message || typeof message !== 'string' || message.length > 4096) {
|
||||||
return res.status(400).json({ status: 'error', message: 'Invalid message.'});
|
return res.status(400).json({ status: 'error', message: 'Invalid message.'});
|
||||||
|
|
@ -79,7 +95,10 @@ router.post('/emma/chat', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emmaData = await callAgent('run_emma', { prompt: message, history: [] });
|
const emmaData = await callAgent('run_emma', {
|
||||||
|
prompt: message,
|
||||||
|
history: normalizeHistory(history),
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,32 @@ interface User {
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [authChecked, setAuthChecked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/auth/me', { credentials: 'include' })
|
fetch('/auth/me', { credentials: 'include' })
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then(setUser);
|
.then(setUser)
|
||||||
|
.finally(() => setAuthChecked(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!authChecked) {
|
||||||
|
return null; // or a loading spinner
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main className="login">
|
<div className="login">
|
||||||
<h1>OPAX</h1>
|
<h1>OPAX</h1>
|
||||||
<a href="/auth/google">Continue with Google</a>
|
<a href="/auth/google">Continue with Google</a>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<div className="emma-workspace">
|
||||||
<header>
|
<OperationsConsole user={user} />
|
||||||
<b>OPAX / Operations Console</b>
|
</div>
|
||||||
<span>{user.email}</span>
|
|
||||||
</header>
|
|
||||||
<OperationsConsole />
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,160 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface Message {
|
export interface Message {
|
||||||
role: string;
|
id: string;
|
||||||
content: string;
|
role: 'user' | 'assistant' | 'status';
|
||||||
|
content: string;
|
||||||
|
isError?: boolean;
|
||||||
|
retryPayload?: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWorkspace = () => {
|
interface ChatWorkspaceProps {
|
||||||
const [message, setMessage] = useState('');
|
messages: Message[];
|
||||||
const [transcript, setTranscript] = useState<Message[]>([]);
|
isLoading: boolean;
|
||||||
const [loading, setLoading] = useState(false);
|
onSendMessage: (message: string) => void;
|
||||||
const [error, setError] = useState<string | null>(null);
|
onRetry: (originalMessage: Message, errorStatusId: string) => void;
|
||||||
const [emmaStatus, setEmmaStatus] = useState<string>('unavailable');
|
composerValue: string;
|
||||||
const [ticketNumber, setTicketNumber] = useState<number | null>(null);
|
setComposerValue: (value: string) => void;
|
||||||
|
composerRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const StarterPrompts = ({ onSelect }: { onSelect: (prompt: string) => void }) => {
|
||||||
const fetchEmmaStatus = async () => {
|
const prompts = [
|
||||||
try {
|
'Gi meg en kort status på det viktigste jeg bør følge opp.',
|
||||||
const res = await fetch('/api/console/emma/status', { credentials: 'include' });
|
'Hjelp meg å strukturere neste arbeidsøkt.',
|
||||||
const data = await res.json();
|
'Oppsummer risikoer og neste beslutninger.',
|
||||||
setEmmaStatus(data.status);
|
'Lag et utkast til en konkret oppfølgingsplan.',
|
||||||
} catch (e) {
|
];
|
||||||
setEmmaStatus('unavailable');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchEmmaStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
return (
|
||||||
if (!message.trim()) return;
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-mark">◇</div>
|
||||||
|
<h1>Hva vil du få oversikt over i dag?</h1>
|
||||||
|
<p>Emma hjelper deg å strukturere arbeid, analysere situasjoner og finne tydelige neste steg.</p>
|
||||||
|
<div className="starter-prompts">
|
||||||
|
{prompts.map((p) => (
|
||||||
|
<button key={p} className="starter-prompt" onClick={() => onSelect(p)}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const newMessage: Message = { role: 'user', content: message };
|
const PromptComposer = ({ onSend, isLoading, value, setValue, composerRef }: {
|
||||||
setTranscript(prev => [...prev, newMessage]);
|
onSend: (val: string) => void;
|
||||||
setMessage('');
|
isLoading: boolean;
|
||||||
setLoading(true);
|
value: string;
|
||||||
setError(null);
|
setValue: (val: string) => void;
|
||||||
|
composerRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (composerRef.current) {
|
||||||
|
composerRef.current.style.height = 'auto';
|
||||||
|
composerRef.current.style.height = `${composerRef.current.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}, [value, composerRef]);
|
||||||
|
|
||||||
try {
|
const handleSend = () => {
|
||||||
const res = await fetch('/api/console/emma/chat', {
|
if (value.trim()) onSend(value.trim());
|
||||||
method: 'POST',
|
};
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ message, ticket_number: ticketNumber }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
throw new Error('Emma is unavailable.');
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
}
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const data = await res.json();
|
return (
|
||||||
const emmaMessage: Message = { role: 'assistant', content: data.reply };
|
<div className="composer-container">
|
||||||
setTranscript(prev => [...prev, emmaMessage]);
|
<div className="composer-inner">
|
||||||
|
<div className="composer">
|
||||||
} catch (err: any) {
|
<textarea
|
||||||
setError(err.message);
|
ref={composerRef}
|
||||||
} finally {
|
value={value}
|
||||||
setLoading(false);
|
onChange={(e) => setValue(e.target.value)}
|
||||||
}
|
onKeyDown={handleKeyDown}
|
||||||
};
|
placeholder="Spør Emma om status, analyse eller neste steg …"
|
||||||
|
disabled={isLoading}
|
||||||
return (
|
rows={1}
|
||||||
<div className="chat-workspace">
|
aria-label="Chat message input"
|
||||||
<div className="chat-header">
|
/>
|
||||||
<h3>Emma Local</h3>
|
<button onClick={handleSend} disabled={isLoading || !value.trim()} aria-label="Send message">
|
||||||
<span>{emmaStatus === 'ready' ? 'Connected' : 'Unavailable'}</span>
|
▲
|
||||||
{ticketNumber && <span className="ticket-chip">Gitea #{ticketNumber}</span>}
|
</button>
|
||||||
</div>
|
|
||||||
<div className="transcript">
|
|
||||||
{transcript.length === 0 && !loading && (
|
|
||||||
<div className="welcome-message">
|
|
||||||
<p>Hei Chris. Hva vil du at jeg skal få gjort?</p>
|
|
||||||
<div className="quick-prompts">
|
|
||||||
<button>Status på plattformen</button>
|
|
||||||
<button>Hva er neste oppgave?</button>
|
|
||||||
<button>Arbeid med Gitea #1</button>
|
|
||||||
<button>Sjekk siste build</button>
|
|
||||||
<button>Vis kostnadsavvik</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{transcript.map((msg, index) => (
|
|
||||||
<div key={index} className={`message ${msg.role}`}>
|
|
||||||
<strong>{msg.role}: </strong>{msg.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{loading && <div className="message assistant">...</div>}
|
|
||||||
{error && <div className="message error">{error} <button onClick={handleSend}>Retry</button></div>}
|
|
||||||
</div>
|
|
||||||
<div className="chat-composer">
|
|
||||||
<textarea
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
|
||||||
placeholder="Chat with Emma..."
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button onClick={handleSend} disabled={loading}>
|
|
||||||
{loading ? 'Sending...' : 'Send'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatWorkspace: React.FC<ChatWorkspaceProps> = ({ messages, isLoading, onSendMessage, onRetry, composerValue, setComposerValue, composerRef }) => {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
const handleStarterSelect = (prompt: string) => {
|
||||||
|
setComposerValue(prompt);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (composerRef.current) {
|
||||||
|
composerRef.current.focus();
|
||||||
|
composerRef.current.setSelectionRange(prompt.length, prompt.length);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-workspace">
|
||||||
|
<div className="transcript" ref={scrollRef}>
|
||||||
|
<div className="transcript-inner">
|
||||||
|
{messages.length === 0 && !isLoading ? (
|
||||||
|
<StarterPrompts onSelect={handleStarterSelect} />
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => {
|
||||||
|
if (msg.role === 'status' && msg.isError) {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="message assistant">
|
||||||
|
<div className="error-message" role="alert">
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
{msg.retryPayload && <button onClick={() => onRetry(msg.retryPayload!, msg.id)}>Prøv igjen</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={`message ${msg.role}`}>
|
||||||
|
<div className="message-content">
|
||||||
|
<div className="message-role" aria-hidden="true">{msg.role}</div>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="message assistant">
|
||||||
|
<div className="message-content">
|
||||||
|
<div className="loading-indicator" aria-live="polite">Emma tenker …</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PromptComposer
|
||||||
|
onSend={onSendMessage}
|
||||||
|
isLoading={isLoading}
|
||||||
|
value={composerValue}
|
||||||
|
setValue={setComposerValue}
|
||||||
|
composerRef={composerRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChatWorkspace;
|
export default ChatWorkspace;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,64 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const Sidebar = () => {
|
interface User {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
user: User | null;
|
||||||
|
conversations: Conversation[];
|
||||||
|
activeConversationId: string | null;
|
||||||
|
onNewConversation: () => void;
|
||||||
|
onSelectConversation: (id: string) => void;
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
user,
|
||||||
|
conversations,
|
||||||
|
activeConversationId,
|
||||||
|
onNewConversation,
|
||||||
|
onSelectConversation,
|
||||||
|
isSidebarOpen,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div id="sidebar" className={`sidebar ${isSidebarOpen ? 'open' : ''}`}>
|
||||||
<div className="logo">OPAX</div>
|
<div className="sidebar-header">
|
||||||
<div className="emma-status">Emma Local: Connected</div>
|
<div className="logo">OPAX</div>
|
||||||
<nav>
|
<div className="sub-logo">Emma workspace</div>
|
||||||
<ul>
|
|
||||||
<li className="active">Chat</li>
|
|
||||||
<li>Work</li>
|
|
||||||
<li>Repository</li>
|
|
||||||
<li>Deployments</li>
|
|
||||||
<li>Costs</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div className="ticket-context">
|
|
||||||
<div>Current selected ticket:</div>
|
|
||||||
<div>No active task</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="profile">User Profile</div>
|
|
||||||
|
<button className="new-conversation-button" onClick={onNewConversation}>
|
||||||
|
Ny samtale
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="sidebar-divider"></div>
|
||||||
|
|
||||||
|
<div className="session-heading">Denne økten</div>
|
||||||
|
<div className="conversation-list">
|
||||||
|
{conversations.map((conv) => (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
className={`conversation-item ${conv.id === activeConversationId ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectConversation(conv.id)}
|
||||||
|
title={conv.title}
|
||||||
|
aria-current={conv.id === activeConversationId ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{conv.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="sidebar-footer" title={user.email}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,158 @@
|
||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
import ChatWorkspace from '../components/ChatWorkspace';
|
import ChatWorkspace, { Message } from '../components/ChatWorkspace';
|
||||||
import ContextPanel from '../components/ContextPanel';
|
|
||||||
|
const generateUUID = () => {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
var r = (Math.random() * 16) | 0,
|
||||||
|
v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OperationsConsole = ({ user }: { user: User | null }) => {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [composerValue, setComposerValue] = useState('');
|
||||||
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const executeEmmaRequest = useCallback(async (prompt: string, baseMessages: Message[], historyMessages: Message[]) => {
|
||||||
|
if (!activeConversationId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const history = historyMessages
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
(message.role === 'user' || message.role === 'assistant') &&
|
||||||
|
message.content.trim().length > 0
|
||||||
|
)
|
||||||
|
.slice(-40)
|
||||||
|
.map(({ role, content }) => ({ role, content }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/console/emma/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ message: prompt, history }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Network response was not ok');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status !== 'success' || typeof data.reply !== 'string') {
|
||||||
|
throw new Error(data.message || 'Failed to get a valid reply from Emma.');
|
||||||
|
}
|
||||||
|
const emmaMessage: Message = { id: generateUUID(), role: 'assistant', content: data.reply };
|
||||||
|
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: [...baseMessages, emmaMessage] } : c));
|
||||||
|
} catch (e) {
|
||||||
|
const errorStatus: Message = {
|
||||||
|
id: generateUUID(),
|
||||||
|
role: 'status',
|
||||||
|
content: 'Noe gikk galt. Kunne ikke hente svar fra Emma.',
|
||||||
|
isError: true,
|
||||||
|
retryPayload: { id: generateUUID(), role: 'user', content: prompt }, // Self-contained retry info
|
||||||
|
};
|
||||||
|
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: [...baseMessages, errorStatus] } : c));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeConversationId]);
|
||||||
|
|
||||||
|
const handleSendMessage = async (messageContent: string) => {
|
||||||
|
if (!activeConversationId || !messageContent.trim()) return;
|
||||||
|
setComposerValue('');
|
||||||
|
const activeConvo = conversations.find(c => c.id === activeConversationId);
|
||||||
|
if (!activeConvo) return;
|
||||||
|
|
||||||
|
const userMessage: Message = { id: generateUUID(), role: 'user', content: messageContent };
|
||||||
|
const newBaseMessages = [...activeConvo.messages, userMessage];
|
||||||
|
|
||||||
|
// Optimistically update title
|
||||||
|
if (activeConvo.messages.length === 0) {
|
||||||
|
const newTitle = messageContent.substring(0, 40) + (messageContent.length > 40 ? '...' : '');
|
||||||
|
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, title: newTitle, messages: newBaseMessages } : c));
|
||||||
|
} else {
|
||||||
|
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: newBaseMessages } : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeEmmaRequest(messageContent, newBaseMessages, activeConvo.messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = (originalMessage: Message, errorStatusId: string) => {
|
||||||
|
if (!activeConversationId) return;
|
||||||
|
const activeConvo = conversations.find(c => c.id === activeConversationId);
|
||||||
|
if (!activeConvo) return;
|
||||||
|
|
||||||
|
const messagesWithoutError = activeConvo.messages.filter(m => m.id !== errorStatusId);
|
||||||
|
const historyBeforeRetryPrompt = messagesWithoutError.slice(0, -1);
|
||||||
|
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: messagesWithoutError } : c));
|
||||||
|
|
||||||
|
executeEmmaRequest(originalMessage.content, messagesWithoutError, historyBeforeRetryPrompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewConversation = useCallback(() => {
|
||||||
|
const newConversation: Conversation = { id: generateUUID(), title: 'Ny samtale', messages: [] };
|
||||||
|
setConversations((prev) => [...prev, newConversation]);
|
||||||
|
setActiveConversationId(newConversation.id);
|
||||||
|
if (window.innerWidth <= 768) setSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversations.length === 0) handleNewConversation();
|
||||||
|
}, [conversations.length, handleNewConversation]);
|
||||||
|
|
||||||
|
const handleSelectConversation = (id: string) => {
|
||||||
|
setActiveConversationId(id);
|
||||||
|
if (window.innerWidth <= 768) setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeConversation = conversations.find(c => c.id === activeConversationId);
|
||||||
|
|
||||||
const OperationsConsole = () => {
|
|
||||||
return (
|
return (
|
||||||
<div className="console-layout">
|
<>
|
||||||
<Sidebar />
|
<button
|
||||||
<ChatWorkspace />
|
className="mobile-menu-button"
|
||||||
<ContextPanel />
|
onClick={() => setSidebarOpen(!isSidebarOpen)}
|
||||||
</div>
|
aria-label={isSidebarOpen ? "Lukk meny" : "Åpne meny"}
|
||||||
|
aria-controls="sidebar"
|
||||||
|
aria-expanded={isSidebarOpen}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<Sidebar
|
||||||
|
user={user}
|
||||||
|
conversations={conversations}
|
||||||
|
activeConversationId={activeConversationId}
|
||||||
|
onNewConversation={handleNewConversation}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
|
/>
|
||||||
|
<main className="main-content">
|
||||||
|
<ChatWorkspace
|
||||||
|
messages={activeConversation?.messages || []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
composerValue={composerValue}
|
||||||
|
setComposerValue={setComposerValue}
|
||||||
|
composerRef={composerRef}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,33 @@
|
||||||
:root {
|
:root {
|
||||||
--font-family: monospace;
|
--font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--background-color: #0d1117;
|
--background-color: #111118; /* Deep graphite */
|
||||||
--text-color: #e6edf3;
|
--sidebar-background: #191921;
|
||||||
|
--composer-background: #20212c;
|
||||||
|
--message-user-background: #2a2a36;
|
||||||
|
--text-color: #e1e1e6;
|
||||||
--text-color-secondary: #8b949e;
|
--text-color-secondary: #8b949e;
|
||||||
|
--text-color-placeholder: #6a6a79;
|
||||||
--border-color: #30363d;
|
--border-color: #30363d;
|
||||||
--container-background: #161b22;
|
--accent-color: #00a8cc; /* Restrained cyan/teal */
|
||||||
--button-background: #21262d;
|
--accent-color-hover: #00c0e8;
|
||||||
--button-background-hover: #30363d;
|
|
||||||
--button-primary-background: #238636;
|
|
||||||
--button-primary-background-hover: #2ea043;
|
|
||||||
--error-color: #f85149;
|
--error-color: #f85149;
|
||||||
--spacing-unit: 16px;
|
--spacing-unit: 16px;
|
||||||
|
--sidebar-width: 260px;
|
||||||
|
--reading-width: 760px;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--composer-border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*::before,
|
*, *::before, *::after {
|
||||||
*::after {
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,131 +36,10 @@ body {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: var(--spacing-unit);
|
|
||||||
background-color: var(--container-background);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
header span {
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color: var(--text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-wrapper {
|
|
||||||
padding: calc(var(--spacing-unit) * 2);
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: calc(var(--spacing-unit) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header p {
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-color-secondary);
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: calc(var(--spacing-unit) * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.actions-container {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-card {
|
|
||||||
background-color: var(--container-background);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: var(--spacing-unit);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-card h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding-bottom: var(--spacing-unit);
|
|
||||||
margin-bottom: var(--spacing-unit);
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--button-background);
|
|
||||||
color: var(--text-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 10px var(--spacing-unit);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
|
||||||
background-color: var(--button-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary {
|
|
||||||
background-color: var(--button-primary-background);
|
|
||||||
border-color: var(--button-primary-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary:hover:not(:disabled) {
|
|
||||||
background-color: var(--button-primary-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-display {
|
|
||||||
margin-top: var(--spacing-unit);
|
|
||||||
padding: var(--spacing-unit);
|
|
||||||
background-color: var(--background-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
font-size: 13px;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-display strong {
|
|
||||||
color: var(--text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--error-color);
|
|
||||||
margin-top: calc(var(--spacing-unit) / 2);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login {
|
.login {
|
||||||
|
|
@ -159,7 +50,362 @@ button.primary:hover:not(:disabled) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.login a {
|
.login a {
|
||||||
color: #58a6ff;
|
background: var(--accent-color);
|
||||||
font-size: 18px;
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login a:hover {
|
||||||
|
background: var(--accent-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emma-workspace {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--sidebar-background);
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--spacing-unit);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-unit);
|
||||||
|
left: var(--spacing-unit);
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--composer-background);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 99;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding-bottom: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header .logo {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header .sub-logo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-conversation-button {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: var(--spacing-unit) 0;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-conversation-button:hover {
|
||||||
|
background: var(--accent-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin: var(--spacing-unit) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-heading {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover {
|
||||||
|
background-color: var(--composer-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item.active {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding-top: var(--spacing-unit);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ChatWorkspace Styles */
|
||||||
|
.chat-workspace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-inner {
|
||||||
|
max-width: var(--reading-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: calc(var(--spacing-unit) * 2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 10vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-mark {
|
||||||
|
font-size: 32px; /* Replace with a real CSS mark if needed */
|
||||||
|
color: var(--accent-color);
|
||||||
|
margin-bottom: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 calc(var(--spacing-unit) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 0 auto calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-prompts {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-unit);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-prompt {
|
||||||
|
background: var(--composer-background);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.starter-prompt:hover {
|
||||||
|
background: var(--message-user-background);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 1.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-content {
|
||||||
|
background: var(--message-user-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant .message-content {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator, .error-message {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message button {
|
||||||
|
width: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message button:hover {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Composer Styles */
|
||||||
|
.composer-container {
|
||||||
|
padding: var(--spacing-unit) 0;
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-inner {
|
||||||
|
max-width: var(--reading-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
background: var(--composer-background);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--composer-border-radius);
|
||||||
|
padding: 8px 8px 8px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer:focus-within {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
flex-grow: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 0;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea::placeholder {
|
||||||
|
color: var(--text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer button {
|
||||||
|
background: var(--accent-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer button:hover:not(:disabled) {
|
||||||
|
background-color: var(--accent-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer button:disabled {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});
|
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/api':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});
|
||||||
|
|
|
||||||
24
scripts/check-live-services.sh
Executable file
24
scripts/check-live-services.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ID="propane-will-491900-m5"
|
||||||
|
REGION="us-central1"
|
||||||
|
|
||||||
|
for SERVICE in opax-mcp opax-web; do
|
||||||
|
echo
|
||||||
|
echo "==> ${SERVICE}"
|
||||||
|
|
||||||
|
gcloud run services describe "${SERVICE}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--format="table(
|
||||||
|
metadata.name,
|
||||||
|
status.url,
|
||||||
|
status.latestReadyRevisionName,
|
||||||
|
status.traffic[0].revisionName,
|
||||||
|
spec.template.spec.containers[0].image
|
||||||
|
)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Operator URL: https://opax.vauco.no"
|
||||||
104
scripts/deploy-mcp.sh
Executable file
104
scripts/deploy-mcp.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
PROJECT_ID="propane-will-491900-m5"
|
||||||
|
REGION="us-central1"
|
||||||
|
REPOSITORY="us-central1-docker.pkg.dev/${PROJECT_ID}/osvauco-repo"
|
||||||
|
MCP_SERVICE="opax-mcp"
|
||||||
|
MCP_IMAGE="${REPOSITORY}/opax-mcp"
|
||||||
|
|
||||||
|
echo "==> Submitting MCP Cloud Build"
|
||||||
|
|
||||||
|
BUILD_ID="$(
|
||||||
|
gcloud builds submit \
|
||||||
|
--async \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--config="cloudbuild.deploy.yaml" \
|
||||||
|
--format="value(id)" \
|
||||||
|
.
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "${BUILD_ID}" ]]; then
|
||||||
|
echo "ERROR: Cloud Build did not return a build ID." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Cloud Build ID: ${BUILD_ID}"
|
||||||
|
echo "==> Streaming Cloud Build logs"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
gcloud builds log "${BUILD_ID}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--stream
|
||||||
|
BUILD_LOG_EXIT_CODE=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BUILD_STATUS="$(
|
||||||
|
gcloud builds describe "${BUILD_ID}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--format="value(status)"
|
||||||
|
)"
|
||||||
|
|
||||||
|
IMAGE_REF="$(
|
||||||
|
gcloud builds describe "${BUILD_ID}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--format="value(results.images[0].name)"
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "${IMAGE_REF}" ]]; then
|
||||||
|
IMAGE_REF="${MCP_IMAGE}:${BUILD_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${IMAGE_REF}" == *@sha256:* ]]; then
|
||||||
|
DEPLOY_IMAGE="${IMAGE_REF}"
|
||||||
|
else
|
||||||
|
IMAGE_DIGEST="$(
|
||||||
|
gcloud artifacts docker images describe "${IMAGE_REF}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--format="value(image_summary.digest)"
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "${IMAGE_DIGEST}" ]]; then
|
||||||
|
echo "ERROR: No pushed MCP image digest was found." >&2
|
||||||
|
echo "Cloud Build status: ${BUILD_STATUS}" >&2
|
||||||
|
echo "Cloud Build log exit code: ${BUILD_LOG_EXIT_CODE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_IMAGE="${MCP_IMAGE}@${IMAGE_DIGEST}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Cloud Build status: ${BUILD_STATUS}"
|
||||||
|
echo "==> Deploying immutable MCP image:"
|
||||||
|
echo "${DEPLOY_IMAGE}"
|
||||||
|
|
||||||
|
gcloud run deploy "${MCP_SERVICE}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--image="${DEPLOY_IMAGE}" \
|
||||||
|
--quiet
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Active MCP deployment"
|
||||||
|
|
||||||
|
gcloud run services describe "${MCP_SERVICE}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--format="table(
|
||||||
|
metadata.name,
|
||||||
|
status.url,
|
||||||
|
status.latestReadyRevisionName,
|
||||||
|
status.traffic[0].revisionName,
|
||||||
|
spec.template.spec.containers[0].image
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "MCP deployment complete."
|
||||||
|
echo "Cloud Build log exit code: ${BUILD_LOG_EXIT_CODE}"
|
||||||
60
scripts/deploy-opax-web.sh
Executable file
60
scripts/deploy-opax-web.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
PROJECT_ID="propane-will-491900-m5"
|
||||||
|
REGION="us-central1"
|
||||||
|
REPOSITORY="us-central1-docker.pkg.dev/${PROJECT_ID}/osvauco-repo"
|
||||||
|
WEB_SERVICE="opax-web"
|
||||||
|
WEB_IMAGE="${REPOSITORY}/opax-web"
|
||||||
|
TAG="manual-$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
echo "==> Building OPAX Web image:"
|
||||||
|
echo "${WEB_IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
gcloud builds submit opax-web \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--tag="${WEB_IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
WEB_DIGEST="$(
|
||||||
|
gcloud artifacts docker images describe "${WEB_IMAGE}:${TAG}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--format="value(image_summary.digest)"
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z "${WEB_DIGEST}" ]]; then
|
||||||
|
echo "ERROR: No immutable OPAX Web image digest was found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_IMAGE="${WEB_IMAGE}@${WEB_DIGEST}"
|
||||||
|
|
||||||
|
echo "==> Deploying immutable OPAX Web image:"
|
||||||
|
echo "${DEPLOY_IMAGE}"
|
||||||
|
|
||||||
|
gcloud run deploy "${WEB_SERVICE}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--image="${DEPLOY_IMAGE}" \
|
||||||
|
--quiet
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Active OPAX Web deployment"
|
||||||
|
|
||||||
|
gcloud run services describe "${WEB_SERVICE}" \
|
||||||
|
--project="${PROJECT_ID}" \
|
||||||
|
--region="${REGION}" \
|
||||||
|
--format="table(
|
||||||
|
metadata.name,
|
||||||
|
status.url,
|
||||||
|
status.latestReadyRevisionName,
|
||||||
|
status.traffic[0].revisionName,
|
||||||
|
spec.template.spec.containers[0].image
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Live operator URL: https://opax.vauco.no"
|
||||||
|
echo "Hard refresh after deployment: Ctrl+Shift+R"
|
||||||
Loading…
Reference in New Issue
Block a user