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,
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
payload = { "model": model, "messages": messages, "stream": False }
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as c:
|
||||
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):
|
||||
"""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")})
|
||||
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:
|
||||
"""Kaller den kanoniske Emma-agenten med en prompt."""
|
||||
capability_system_context = build_capability_system_context()
|
||||
return await canonical_emma.run(
|
||||
prompt=p.get("prompt", p.get("message", "")),
|
||||
history=[],
|
||||
history=_normalize_emma_history(p.get("history")),
|
||||
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..."))
|
||||
|
|
@ -1164,7 +1196,7 @@ TOOLS = {
|
|||
),
|
||||
# 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_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
|
||||
"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) => {
|
||||
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) {
|
||||
return res.status(400).json({ status: 'error', message: 'Invalid message.'});
|
||||
|
|
@ -79,7 +95,10 @@ router.post('/emma/chat', async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const emmaData = await callAgent('run_emma', { prompt: message, history: [] });
|
||||
const emmaData = await callAgent('run_emma', {
|
||||
prompt: message,
|
||||
history: normalizeHistory(history),
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
|
|
|
|||
|
|
@ -8,30 +8,32 @@ interface User {
|
|||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/auth/me', { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then(setUser);
|
||||
.then(setUser)
|
||||
.finally(() => setAuthChecked(true));
|
||||
}, []);
|
||||
|
||||
if (!authChecked) {
|
||||
return null; // or a loading spinner
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="login">
|
||||
<div className="login">
|
||||
<h1>OPAX</h1>
|
||||
<a href="/auth/google">Continue with Google</a>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<b>OPAX / Operations Console</b>
|
||||
<span>{user.email}</span>
|
||||
</header>
|
||||
<OperationsConsole />
|
||||
</main>
|
||||
<div className="emma-workspace">
|
||||
<OperationsConsole user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,105 +1,160 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface Message {
|
||||
role: string;
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'status';
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
retryPayload?: Message;
|
||||
}
|
||||
|
||||
const ChatWorkspace = () => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [transcript, setTranscript] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emmaStatus, setEmmaStatus] = useState<string>('unavailable');
|
||||
const [ticketNumber, setTicketNumber] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEmmaStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/console/emma/status', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
setEmmaStatus(data.status);
|
||||
} catch (e) {
|
||||
setEmmaStatus('unavailable');
|
||||
}
|
||||
};
|
||||
fetchEmmaStatus();
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) return;
|
||||
|
||||
const newMessage: Message = { role: 'user', content: message };
|
||||
setTranscript(prev => [...prev, newMessage]);
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/console/emma/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ message, ticket_number: ticketNumber }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Emma is unavailable.');
|
||||
interface ChatWorkspaceProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
onSendMessage: (message: string) => void;
|
||||
onRetry: (originalMessage: Message, errorStatusId: string) => void;
|
||||
composerValue: string;
|
||||
setComposerValue: (value: string) => void;
|
||||
composerRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const emmaMessage: Message = { role: 'assistant', content: data.reply };
|
||||
setTranscript(prev => [...prev, emmaMessage]);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const StarterPrompts = ({ onSelect }: { onSelect: (prompt: string) => void }) => {
|
||||
const prompts = [
|
||||
'Gi meg en kort status på det viktigste jeg bør følge opp.',
|
||||
'Hjelp meg å strukturere neste arbeidsøkt.',
|
||||
'Oppsummer risikoer og neste beslutninger.',
|
||||
'Lag et utkast til en konkret oppfølgingsplan.',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="chat-workspace">
|
||||
<div className="chat-header">
|
||||
<h3>Emma Local</h3>
|
||||
<span>{emmaStatus === 'ready' ? 'Connected' : 'Unavailable'}</span>
|
||||
{ticketNumber && <span className="ticket-chip">Gitea #{ticketNumber}</span>}
|
||||
</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'}
|
||||
<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 PromptComposer = ({ onSend, isLoading, value, setValue, composerRef }: {
|
||||
onSend: (val: string) => void;
|
||||
isLoading: boolean;
|
||||
value: string;
|
||||
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]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (value.trim()) onSend(value.trim());
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="composer-container">
|
||||
<div className="composer-inner">
|
||||
<div className="composer">
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Spør Emma om status, analyse eller neste steg …"
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
aria-label="Chat message input"
|
||||
/>
|
||||
<button onClick={handleSend} disabled={isLoading || !value.trim()} aria-label="Send message">
|
||||
▲
|
||||
</button>
|
||||
</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;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,64 @@
|
|||
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 (
|
||||
<div className="sidebar">
|
||||
<div id="sidebar" className={`sidebar ${isSidebarOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">OPAX</div>
|
||||
<div className="emma-status">Emma Local: Connected</div>
|
||||
<nav>
|
||||
<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 className="sub-logo">Emma workspace</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,158 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import ChatWorkspace from '../components/ChatWorkspace';
|
||||
import ContextPanel from '../components/ContextPanel';
|
||||
import ChatWorkspace, { Message } from '../components/ChatWorkspace';
|
||||
|
||||
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 (
|
||||
<div className="console-layout">
|
||||
<Sidebar />
|
||||
<ChatWorkspace />
|
||||
<ContextPanel />
|
||||
</div>
|
||||
<>
|
||||
<button
|
||||
className="mobile-menu-button"
|
||||
onClick={() => setSidebarOpen(!isSidebarOpen)}
|
||||
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 {
|
||||
--font-family: monospace;
|
||||
--background-color: #0d1117;
|
||||
--text-color: #e6edf3;
|
||||
--font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--background-color: #111118; /* Deep graphite */
|
||||
--sidebar-background: #191921;
|
||||
--composer-background: #20212c;
|
||||
--message-user-background: #2a2a36;
|
||||
--text-color: #e1e1e6;
|
||||
--text-color-secondary: #8b949e;
|
||||
--text-color-placeholder: #6a6a79;
|
||||
--border-color: #30363d;
|
||||
--container-background: #161b22;
|
||||
--button-background: #21262d;
|
||||
--button-background-hover: #30363d;
|
||||
--button-primary-background: #238636;
|
||||
--button-primary-background-hover: #2ea043;
|
||||
--accent-color: #00a8cc; /* Restrained cyan/teal */
|
||||
--accent-color-hover: #00c0e8;
|
||||
--error-color: #f85149;
|
||||
--spacing-unit: 16px;
|
||||
--sidebar-width: 260px;
|
||||
--reading-width: 760px;
|
||||
--border-radius: 8px;
|
||||
--composer-border-radius: 20px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::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;
|
||||
}
|
||||
|
||||
|
|
@ -24,131 +36,10 @@ body {
|
|||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
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;
|
||||
}
|
||||
|
||||
header span {
|
||||
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;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.login {
|
||||
|
|
@ -159,7 +50,362 @@ button.primary:hover:not(:disabled) {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.login a {
|
||||
color: #58a6ff;
|
||||
font-size: 18px;
|
||||
.login h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login a {
|
||||
background: var(--accent-color);
|
||||
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