feat: add opencode embedded webui
This commit is contained in:
@@ -16,7 +16,7 @@ const CLIENT_DISCONNECT_ERROR_CODES = new Set([
|
||||
]);
|
||||
const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu;
|
||||
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
|
||||
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
|
||||
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
|
||||
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
||||
const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}";
|
||||
const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu;
|
||||
@@ -131,9 +131,18 @@ function runtimeConfigFromEnv() {
|
||||
const config = { displayTime: displayTimeConfigFromEnv() };
|
||||
const workbench = workbenchRuntimeConfigFromEnv();
|
||||
if (workbench) config.workbench = workbench;
|
||||
const opencode = opencodeRuntimeConfigFromEnv();
|
||||
if (opencode) config.opencode = opencode;
|
||||
return config;
|
||||
}
|
||||
|
||||
function opencodeRuntimeConfigFromEnv() {
|
||||
const url = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
|
||||
if (!url) return null;
|
||||
validateOpencodeUrl(url);
|
||||
return { url };
|
||||
}
|
||||
|
||||
function displayTimeConfigFromEnv() {
|
||||
const timeZone = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE");
|
||||
const locale = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE");
|
||||
@@ -151,6 +160,28 @@ function requiredRuntimeConfigEnv(name) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalRuntimeConfigEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function validateOpencodeUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: must be an absolute http(s) URL");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: only http(s) URLs are allowed");
|
||||
}
|
||||
if (url.username || url.password || url.hash) {
|
||||
throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: credentials and fragments are not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
function validateDisplayTimeZone(timeZone) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0));
|
||||
|
||||
@@ -982,6 +982,7 @@ function secretNameForProfile(secretName, profile) {
|
||||
if (secretName === "hwlab-cloud-api-dev-db") return `hwlab-cloud-api-${profile}-db`;
|
||||
if (secretName === "hwlab-code-agent-provider") return `hwlab-${profile}-code-agent-provider`;
|
||||
if (secretName === "hwlab-code-agent-codex-auth") return `hwlab-${profile}-code-agent-codex-auth`;
|
||||
if (secretName === "hwlab-opencode-server-auth") return `hwlab-${profile}-opencode-server-auth`;
|
||||
if (secretName === "hwlab-bootstrap-admin") return `hwlab-${profile}-bootstrap-admin`;
|
||||
return secretName;
|
||||
}
|
||||
@@ -5637,6 +5638,98 @@ function secretPlaneIssueLabelValue(issueRef) {
|
||||
return label;
|
||||
}
|
||||
|
||||
function opencodeServerManifest({ profile = "v03", source }) {
|
||||
assert.ok(isRuntimeLane(profile), `opencode-server profile must be a runtime lane, got ${profile}`);
|
||||
const namespace = namespaceNameForProfile(profile);
|
||||
const name = "opencode-server";
|
||||
const selector = {
|
||||
"app.kubernetes.io/name": name,
|
||||
"app.kubernetes.io/part-of": "hwlab",
|
||||
"hwlab.pikastech.local/environment": profile,
|
||||
"hwlab.pikastech.local/profile": profile
|
||||
};
|
||||
const labels = {
|
||||
...selector,
|
||||
"hwlab.pikastech.local/component": "opencode",
|
||||
"hwlab.pikastech.local/gitops-target": profile,
|
||||
"hwlab.pikastech.local/service-id": name,
|
||||
"hwlab.pikastech.local/source-commit": source.full
|
||||
};
|
||||
const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" };
|
||||
const authSecretName = secretNameForProfile("hwlab-opencode-server-auth", profile);
|
||||
const configContent = JSON.stringify({ autoupdate: false, share: "disabled" });
|
||||
return {
|
||||
apiVersion: "v1",
|
||||
kind: "List",
|
||||
items: [
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ServiceAccount",
|
||||
metadata: { name, namespace, labels, annotations }
|
||||
},
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "PersistentVolumeClaim",
|
||||
metadata: { name: `${name}-data`, namespace, labels, annotations },
|
||||
spec: {
|
||||
accessModes: ["ReadWriteOnce"],
|
||||
resources: { requests: { storage: "8Gi" } }
|
||||
}
|
||||
},
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "Service",
|
||||
metadata: { name, namespace, labels, annotations },
|
||||
spec: {
|
||||
type: "ClusterIP",
|
||||
selector,
|
||||
ports: [{ name: "http", port: 4096, targetPort: "http" }]
|
||||
}
|
||||
},
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: { name, namespace, labels, annotations },
|
||||
spec: {
|
||||
replicas: 1,
|
||||
strategy: { type: "Recreate" },
|
||||
selector: { matchLabels: selector },
|
||||
template: {
|
||||
metadata: { labels, annotations },
|
||||
spec: {
|
||||
serviceAccountName: name,
|
||||
securityContext: { fsGroup: 1000, fsGroupChangePolicy: "OnRootMismatch" },
|
||||
containers: [{
|
||||
name,
|
||||
image: "ghcr.io/anomalyco/opencode:1.17.7",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["opencode"],
|
||||
args: ["serve", "--hostname", "0.0.0.0", "--port", "4096"],
|
||||
workingDir: "/workspace",
|
||||
env: [
|
||||
{ name: "HOME", value: "/workspace" },
|
||||
{ name: "XDG_CONFIG_HOME", value: "/workspace/.config" },
|
||||
{ name: "XDG_DATA_HOME", value: "/workspace/.local/share" },
|
||||
{ name: "OPENCODE_CONFIG_CONTENT", value: configContent },
|
||||
{ name: "OPENCODE_SERVER_USERNAME", valueFrom: { secretKeyRef: { name: authSecretName, key: "username" } } },
|
||||
{ name: "OPENCODE_SERVER_PASSWORD", valueFrom: { secretKeyRef: { name: authSecretName, key: "password" } } }
|
||||
],
|
||||
ports: [{ name: "http", containerPort: 4096 }],
|
||||
startupProbe: { tcpSocket: { port: "http" }, periodSeconds: 5, failureThreshold: 60 },
|
||||
readinessProbe: { tcpSocket: { port: "http" }, periodSeconds: 10, failureThreshold: 3 },
|
||||
livenessProbe: { tcpSocket: { port: "http" }, periodSeconds: 20, failureThreshold: 3 },
|
||||
resources: { requests: { cpu: "100m", memory: "256Mi" }, limits: { cpu: "1", memory: "1Gi" } },
|
||||
volumeMounts: [{ name: "workspace", mountPath: "/workspace" }]
|
||||
}],
|
||||
volumes: [{ name: "workspace", persistentVolumeClaim: { claimName: `${name}-data` } }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function requiredSecretPlaneString(value, label) {
|
||||
assert.equal(typeof value, "string", `${label} must be a string`);
|
||||
const trimmed = value.trim();
|
||||
@@ -5653,7 +5746,7 @@ function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile ==
|
||||
if (externalPostgres) resources.push("external-postgres.yaml");
|
||||
else resources.push("postgres.yaml");
|
||||
if (workbenchRedis) resources.push("workbench-redis.yaml");
|
||||
resources.push("openfga.yaml", "observability.yaml");
|
||||
resources.push("openfga.yaml", "observability.yaml", "opencode.yaml");
|
||||
}
|
||||
if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml");
|
||||
resources.push("node-frpc.yaml");
|
||||
@@ -5812,6 +5905,7 @@ async function plannedFiles(args) {
|
||||
if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source }));
|
||||
if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source }));
|
||||
if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
|
||||
if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source }));
|
||||
if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));
|
||||
putJson(`${runtimePath}/node-frpc.yaml`, nodeFrpcManifest({ profile, source }));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ interface NavSection {
|
||||
}
|
||||
|
||||
const navSections = [
|
||||
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
|
||||
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
|
||||
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] },
|
||||
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] },
|
||||
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] },
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface DisplayTimeConfig {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeFrameConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const DEFAULT_DISPLAY_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
@@ -91,6 +95,14 @@ export function traceExplorerHref(traceId: string | null | undefined): string |
|
||||
}
|
||||
}
|
||||
|
||||
export function opencodeFrameUrl(): string | null {
|
||||
const value = window.HWLAB_CLOUD_WEB_CONFIG?.opencode?.url;
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!isSafeOpencodeFrameUrl(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizedTraceId(value: string | null | undefined): string | null {
|
||||
const traceId = typeof value === "string" ? value.trim() : "";
|
||||
return TRACE_ID_PATTERN.test(traceId) ? traceId : null;
|
||||
@@ -110,6 +122,16 @@ function isSafeTraceExplorerUrlTemplate(template: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeOpencodeFrameUrl(value: string): boolean {
|
||||
if (!value || value.startsWith("//")) return false;
|
||||
try {
|
||||
const url = new URL(value, runtimeOrigin());
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeOrigin(): string {
|
||||
return typeof window.location?.origin === "string" && window.location.origin.length > 0
|
||||
? window.location.origin
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"
|
||||
import { installRouterGuards } from "./guards";
|
||||
|
||||
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
|
||||
const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue");
|
||||
const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
|
||||
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
|
||||
|
||||
@@ -15,6 +16,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, navId: "user.dashboard", title: "平台概览", section: "user" } },
|
||||
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } },
|
||||
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, navId: "project.overview", title: "项目", section: "project" } },
|
||||
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef", name: "ProjectMdtodoFile", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@ declare module "vue-router" {
|
||||
requiresAdmin?: boolean;
|
||||
navId?: string;
|
||||
title?: string;
|
||||
section?: "workbench" | "project" | "admin" | "user" | "system";
|
||||
section?: "workbench" | "opencode" | "project" | "admin" | "user" | "system";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ function accessFromUnknown(value: unknown): AuthAccess | undefined {
|
||||
function firstAllowedNavPath(allowedIds: string[]): string {
|
||||
const ordered = [
|
||||
{ id: "workbench.code", path: "/workbench" },
|
||||
{ id: "opencode.root", path: "/opencode" },
|
||||
{ id: "project.mdtodo", path: "/projects/mdtodo" },
|
||||
{ id: "user.dashboard", path: "/dashboard" },
|
||||
{ id: "project.overview", path: "/projects" },
|
||||
|
||||
+3
@@ -14,6 +14,9 @@ declare global {
|
||||
};
|
||||
traceExplorerUrlTemplate?: string;
|
||||
};
|
||||
opencode?: {
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
__HWLAB_WORKBENCH_UI_TRACE__?: {
|
||||
uiTraceId: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { opencodeFrameUrl } from "@/config/runtime";
|
||||
|
||||
const frameUrl = computed(() => opencodeFrameUrl());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="opencode-frame-view" aria-label="OpenCode">
|
||||
<iframe v-if="frameUrl" class="opencode-frame" :src="frameUrl" title="OpenCode" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="same-origin" />
|
||||
<div v-else class="opencode-frame-empty" role="status">OpenCode 未配置</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.opencode-frame-view {
|
||||
display: flex;
|
||||
min-height: calc(100dvh - 72px);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border, #d5dce7);
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.opencode-frame {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: calc(100dvh - 96px);
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.opencode-frame-empty {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
place-items: center;
|
||||
min-height: 320px;
|
||||
color: #dbe4f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.opencode-frame-view {
|
||||
min-height: calc(100dvh - 56px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.opencode-frame {
|
||||
min-height: calc(100dvh - 56px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user