[WIP] Build Frontend from versioned UI and contract packages #84

Closed
weichen wants to merge 4 commits from feature/versioned-dependencies into main
12 changed files with 604 additions and 423 deletions
+501
View File
@@ -0,0 +1,501 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
GOTOOLCHAIN: local
GOWORK: "off"
GOPRIVATE: git-ssh.u1680832.nyat.app/weichen
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: &trusted_acquisition_1 |
set -euo pipefail
python3 - <<'PY_TRUSTED'
# Trusted workflow code only: never import or execute checkout scripts here.
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
from urllib.parse import urlsplit
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed = urlsplit(SERVER)
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
parsed.username or parsed.password or parsed.query or parsed.fragment):
sys.exit("Acquisition requires the platform-injected http(s) server")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
"Development-Guidelines", "Agent-System-Documents"}
LEGACY = {
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
"pins/agent-system-ui": "Agent-System-UI",
}
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
# Go environment files and inherited Git tracing/config must not change acquisition.
clean = {k: v for k, v in os.environ.items()
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
if os.environ.get("npm_config_cache"):
clean["npm_config_cache"] = os.environ["npm_config_cache"]
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
root = Path.cwd()
mirrors = root / ".ci-mirrors"
def process_env(values):
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return env
def git(directory, *args, token="", repo=""):
values = config.copy()
if token:
if repo not in REPOS:
raise ValueError("Credential destination is outside the reviewed repository allowlist")
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.stdout
def fixed_sha(value):
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
raise ValueError("Source acquisition requires a fixed commit SHA")
return value.lower()
def mirror(repo, sha, token):
sha = fixed_sha(sha)
destination = mirrors / (repo + ".git")
if destination.is_symlink() or mirrors.is_symlink():
raise ValueError("Mirror directory cannot be a symlink")
mirrors.mkdir(exist_ok=True)
if not destination.exists():
destination.mkdir()
git(destination, "init", "--bare", "-q")
url = f"{SERVER}/weichen/{repo}.git"
try:
git(destination, "cat-file", "-e", sha + "^{commit}")
except subprocess.CalledProcessError:
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
git(destination, "update-ref", "refs/heads/main", sha)
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
git(destination, "update-ref", "refs/ci/" + sha, sha)
return destination
def mirror_env():
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
pairs = list(values.items())
for repo in sorted(REPOS):
destination = mirrors / (repo + ".git")
if not destination.exists():
continue
key = "url." + destination.as_uri() + ".insteadOf"
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
for index, (key, value) in enumerate(pairs):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
return env
def materialize_legacy(directory, depth=0):
if depth > 16:
raise ValueError("Historical submodule depth exceeds the checkout limit")
links = {}
for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"):
if entry:
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
if not links:
return
raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
raise ValueError("Unsupported candidate .gitmodules setting")
name, field = match.groups()
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
if set(entries) != set(links):
raise ValueError("Submodule names must match fixed gitlink paths")
for name, fields in entries.items():
repo = LEGACY.get(name)
if not repo or fields.get("path") != name or fields.get("url") not in {
SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}:
raise ValueError("Historical submodule source is outside the reviewed allowlist")
for name in entries:
repo = LEGACY[name]
sha = links[name]
url = f"{SERVER}/weichen/{repo}.git"
git(directory, "config", f"submodule.{name}.url", url)
# Trusted explicit checkout prevents candidate update commands from running.
git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo)
child = directory / name
if git(child, "rev-parse", "HEAD").decode().strip() != sha:
raise ValueError("Historical submodule does not match its fixed SHA")
mirror(repo, sha, component_token)
materialize_legacy(child, depth + 1)
def materialize_manifest(directory):
manifest = directory / "components.json"
lock = directory / "components.lock.json"
if not manifest.exists() and not lock.exists():
materialize_legacy(directory)
return
data = json.loads(manifest.read_text())
locked = json.loads(lock.read_text())
if data.get("schema") != 1 or locked.get("schema") != 1:
raise ValueError("Unsupported source manifest schema")
entries = {}
for item in data["components"]:
repo = item["id"]
path = ".workspace/" + repo
if (repo not in REPOS or repo in entries or item["path"] != path or
item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or
item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}):
raise ValueError("Candidate source manifest is outside the reviewed allowlist")
for module in item.get("modules", []):
if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"):
raise ValueError("Candidate module mapping is outside the reviewed allowlist")
entries[repo] = item
selected = locked["components"]
if set(selected) - set(entries):
raise ValueError("Source lock refers to an unregistered repository")
for repo, item in entries.items():
if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected:
raise ValueError("Application and documentation source needs a fixed lock")
for repo, pin in selected.items():
fixed_sha(pin["commit"])
destination = directory / entries[repo]["path"]
if destination.exists() or destination.is_symlink() or destination.parent.is_symlink():
raise ValueError("Source materialization requires a fresh destination")
# Validate the complete lock before any authenticated component fetch.
for repo, pin in selected.items():
sha = fixed_sha(pin["commit"])
destination = directory / entries[repo]["path"]
destination.mkdir(parents=True)
url = f"{SERVER}/weichen/{repo}.git"
git(destination, "init", "-q")
git(destination, "remote", "add", "origin", url)
git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo)
git(destination, "checkout", "-q", "--detach", "FETCH_HEAD")
if git(destination, "rev-parse", "HEAD").decode().strip() != sha:
raise ValueError("Materialized source differs from the fixed lock")
# Persist the registered identity; process config chooses the transport.
git(destination, "remote", "set-url", "origin", entries[repo]["repo"])
mirror(repo, sha, component_token)
materialize_legacy(destination)
try:
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Frontend":
raise ValueError("Checkout repository is outside the reviewed allowlist")
sha = fixed_sha(os.environ.get("GITHUB_SHA", ""))
if not root_token:
raise ValueError("Checkout requires TOKEN")
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-Frontend.git")
git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-Frontend")
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
if git(root, "rev-parse", "HEAD").decode().strip() != sha:
raise ValueError("Checked-out commit differs from the platform commit")
if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists():
raise ValueError("Candidate cannot provide trusted prefetch state")
materialize_manifest(root)
mirror_env()
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
# Do not print child stderr or environment: a failed credential fetch stays redacted.
sys.exit("Checkout refused: " + str(error))
print("Fixed source checkout complete; credentials were not persisted")
PY_TRUSTED
unset TOKEN SUBMODULE_TOKEN
- name: Install Node 22
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
- name: Prefetch npm packages without lifecycle scripts
env:
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: &trusted_acquisition_2 |
set -euo pipefail
python3 - <<'PY_TRUSTED'
# Trusted workflow code only: never import or execute checkout scripts here.
# Token scopes, workflow provenance and runner isolation are platform NOT_RUN.
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
from urllib.parse import urlsplit
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed = urlsplit(SERVER)
if (parsed.scheme not in {"http", "https"} or not parsed.netloc or
parsed.username or parsed.password or parsed.query or parsed.fragment):
sys.exit("Acquisition requires the platform-injected http(s) server")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/"
REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native",
"Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend",
"Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM",
"Development-Guidelines", "Agent-System-Documents"}
LEGACY = {
"modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend",
"modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI",
"modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core",
"deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native",
"deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent",
"deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System",
"deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend",
"pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend",
"pins/agent-system-ui": "Agent-System-UI",
}
GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"}
GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS}
GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in
("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")}
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
# Go environment files and inherited Git tracing/config must not change acquisition.
clean = {k: v for k, v in os.environ.items()
if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and
not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))}
clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null",
GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly",
GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/"))
if os.environ.get("npm_config_cache"):
clean["npm_config_cache"] = os.environ["npm_config_cache"]
config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "",
"credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"}
root = Path.cwd()
mirrors = root / ".ci-mirrors"
def process_env(values):
env = clean | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return env
def git(directory, *args, token="", repo=""):
values = config.copy()
if token:
if repo not in REPOS:
raise ValueError("Credential destination is outside the reviewed repository allowlist")
values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token
result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values),
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.stdout
def fixed_sha(value):
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value):
raise ValueError("Source acquisition requires a fixed commit SHA")
return value.lower()
def mirror(repo, sha, token):
sha = fixed_sha(sha)
destination = mirrors / (repo + ".git")
if destination.is_symlink() or mirrors.is_symlink():
raise ValueError("Mirror directory cannot be a symlink")
mirrors.mkdir(exist_ok=True)
if not destination.exists():
destination.mkdir()
git(destination, "init", "--bare", "-q")
url = f"{SERVER}/weichen/{repo}.git"
try:
git(destination, "cat-file", "-e", sha + "^{commit}")
except subprocess.CalledProcessError:
git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo)
git(destination, "update-ref", "refs/heads/main", sha)
git(destination, "symbolic-ref", "HEAD", "refs/heads/main")
git(destination, "update-ref", "refs/ci/" + sha, sha)
return destination
def mirror_env():
values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""}
# Distinct keys use repeated .insteadOf values, represented as a sequence below.
pairs = list(values.items())
for repo in sorted(REPOS):
destination = mirrors / (repo + ".git")
if not destination.exists():
continue
key = "url." + destination.as_uri() + ".insteadOf"
pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git",
SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo))
env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))}
for index, (key, value) in enumerate(pairs):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"}
(root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items()))
return env
def source_roots():
bases = [root]
composition = root / ".ci-composition-dev"
if composition.is_dir() and not composition.is_symlink():
bases.append(composition)
paths = []
for base in bases:
paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)]
return [path for path in paths if path.is_dir() and not path.is_symlink()]
def git_package(value):
if not isinstance(value, str):
return None
if value.startswith(("file:", "link:", "workspace:")):
raise ValueError("Formal npm acquisition cannot consume a local package override")
if value.startswith(("http://", "https://")):
registry = urlsplit(value)
if registry.scheme != "https" or registry.hostname != "registry.npmjs.org" or registry.username or registry.password:
raise ValueError("npm tarball source is outside the reviewed public registry")
elif "/" in value and not value.startswith(("git+", "npm:")):
raise ValueError("npm repository shorthand is outside the fixed-SHA package mapping")
if value.startswith("git+") or value.startswith(("git://", "ssh://", "github:", "gitlab:", "bitbucket:")):
for repo in ("Agent-System-Core", "Agent-System-UI"):
prefix = "git+" + SSH + repo + ".git#"
if value.startswith(prefix):
return repo, fixed_sha(value[len(prefix):])
raise ValueError("npm Git source is outside the reviewed fixed-SHA package mapping")
return None
def registry_package(location, entry):
resolved = entry.get("resolved", "")
if not isinstance(resolved, str) or not resolved.startswith(("https://", "http://")):
return None
# A metadata spec is name@exact-version, never a candidate URL.
source = urlsplit(resolved)
if (source.scheme != "https" or source.netloc != "registry.npmjs.org" or
source.username or source.password or source.query or source.fragment):
raise ValueError("npm tarball source is outside the reviewed public registry")
relative = Path(location)
name = entry.get("name") or location.rsplit("node_modules/", 1)[-1]
version = entry.get("version", "")
if (not location.startswith("node_modules/") or relative.is_absolute() or ".." in relative.parts or
not isinstance(name, str) or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*", name) or
not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version)):
raise ValueError("Invalid locked registry package name or version")
return name + "@" + version
try:
targets = []
selected = set()
for directory in source_roots():
if not (directory / "package.json").is_file():
continue
if (directory / ".npmrc").exists():
raise ValueError("Candidate .npmrc is not allowed during private dependency acquisition")
package = json.loads((directory / "package.json").read_text())
lock_bytes = (directory / "package-lock.json").read_bytes()
lock = json.loads(lock_bytes)
if lock.get("lockfileVersion") not in {2, 3}:
raise ValueError("npm acquisition requires a package lock")
for group in ("dependencies", "devDependencies", "optionalDependencies"):
for value in package.get(group, {}).values():
found = git_package(value)
if found:
selected.add(found)
registry_packages = []
for location, entry in lock.get("packages", {}).items():
spec = registry_package(location, entry)
if spec:
registry_packages.append((location, spec))
found = git_package(entry.get("resolved"))
if found:
selected.add(found)
for group in ("dependencies", "devDependencies", "optionalDependencies"):
for value in entry.get(group, {}).values():
found = git_package(value)
if found:
selected.add(found)
targets.append((directory, registry_packages, lock_bytes))
# Fetch only registered fixed Git package commits. npm never receives token config.
for repo, sha in sorted(selected):
repository = mirror(repo, sha, component_token)
package = json.loads(git(repository, "show", sha + ":package.json"))
forbidden = {"prepare", "prepack", "prepublish", "prepublishOnly", "preinstall", "install", "postinstall"}
if forbidden & set(package.get("scripts", {})):
raise ValueError("Git package provider must commit outputs without acquisition lifecycle scripts")
if not git(repository, "ls-tree", sha, "package/").strip():
raise ValueError("Git package provider is missing committed package outputs")
no_auth = mirror_env()
empty_config = tempfile.NamedTemporaryFile(prefix="ci-npm-globalrc-")
for directory, registry_packages, lock_bytes in targets:
result = subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund",
"--userconfig=/dev/null", "--globalconfig=" + empty_config.name],
cwd=directory, env=no_auth, capture_output=True, text=True)
if result.returncode:
raise ValueError("npm dependency acquisition failed: " + result.stderr.strip())
# npm ci caches tarballs; packed consumers also need resolver
# metadata. Only installed locked registry entries are fetched.
specs = sorted({spec for location, spec in registry_packages if (directory / location).is_dir()})
if specs:
with tempfile.TemporaryDirectory(prefix="ci-npm-metadata-") as metadata_root:
result = subprocess.run(["npm", "cache", "add", *specs, "--ignore-scripts",
"--registry=https://registry.npmjs.org", "--userconfig=/dev/null",
"--globalconfig=" + empty_config.name], cwd=metadata_root,
env=no_auth, capture_output=True, text=True)
if result.returncode:
raise ValueError("npm metadata acquisition failed: " + result.stderr.strip())
if (directory / "package-lock.json").read_bytes() != lock_bytes:
raise ValueError("npm acquisition changed the committed package lock")
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit("npm prefetch refused: " + str(error))
print("npm packages cached without credentials or lifecycle execution")
PY_TRUSTED
unset TOKEN SUBMODULE_TOKEN
- name: Validate package and contracts without credentials
run: |
set -euo pipefail
source .ci-mirrors.env
npm test
npm run build
+2 -2
View File
@@ -1,6 +1,6 @@
# Agent 工作说明(Agent-System-Frontend)
本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本):
本仓开发遵循 Development-Guidelines(由 Dev 固定规范来源;新布局见 Dev components.lock.json,规则目录 .workspace/Development-Guidelines;历史布局仍读原 gitlink):
核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。
## 任务启动门禁
@@ -15,7 +15,7 @@
## 仓内规则
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 跨仓语言依赖/应用源码锁变更须经固定组合验证后在消费方推进;源码提交到所属仓;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
- 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。
+5 -3
View File
@@ -2,12 +2,14 @@
- 用途:见 README。定位与边界见设计文档(Agent-System-Documents)。
- 技术栈:React 19 + Vite + TypeScript(Node 仅构建期)
- 构建/验证:见仓内 Makefile 或 Dev 仓顶层 Makefile(待 P1 起补充并保持本节同步)。
- 状态:P0 骨架;当前阶段见 Dev 仓任务记录。
- 构建/验证:见下节及 Dev 仓顶层 Makefile;依赖获取与无凭据执行分阶段。
- 消费入口:UI/Core 契约由固定 Git 提交的 `@agent-system/ui` / `@agent-system/contracts` 包与 npm lock 定位。`@ui` 的运行时/CSS 别名指向 UI 包出口,类型由 `src/ui-alias.d.ts` 重导出该包;无相邻源码路径。`src/contracts.ts` 仅重导出 Core 包类型。
## 验证(当前状态)
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm ci --ignore-scripts` — 依赖获取;移除获取凭据后执行下列验证及构建。
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/,由 Dev 在隔离 Backend 候选中装配后 embed。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- `npm run test:packages` — 将应用及 pack 依赖放入临时目录,无 UI/Core 相邻源码地构建真实 bundle,并检查 React/ReactDOM 各一份。联调可传 `CONTRACTS_TARBALL` / `UI_TARBALL` 的绝对路径,正式依赖文件不保留本地覆盖。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
+8
View File
@@ -3,3 +3,11 @@
对话系统前端:React 19 + Vite + TypeScript 三栏工作台(项目会话 / 流式对话 / 计划与工件面板),含 HITL 卡片、sub-agent 追踪弹窗与 schema 驱动渲染;渲染协议由 agent-system-core 定义。
契约与架构见 Agent-System-Documents 设计方案与架构文档;开发规范见 development-guidelines(Dev 仓 `development/guidelines`)。本仓为 Agent-System 多仓组合的组成部分。
UI 与契约分别消费 `@agent-system/ui`、`@agent-system/contracts`,由 `package.json`
的固定 Git 提交及 `package-lock.json` 锁定。现有 `@ui` 调用和 CSS 引入通过包出口适配;
无需相邻 UI/Core 源码目录。
获取依赖用 `npm ci --ignore-scripts`。移除获取凭据后运行 `npm test`、`npm run build`
与 `npm run test:packages`;最后一项在临时目录通过 pack 制品构建应用并检查 React 单副本。
Frontend 的 `dist/` 是应用制品,Dev 在隔离 Backend 构建目录装配它后编译真实二进制。
+19
View File
@@ -8,6 +8,8 @@
"name": "agent-system-frontend",
"version": "0.1.0",
"dependencies": {
"@agent-system/contracts": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9",
"@agent-system/ui": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git#63d289e6bc15b701356cfe2d5b8740210fac0dcc",
"docx-preview": "^0.4.1",
"dompurify": "^3.4.16",
"jszip": "^3.10.2",
@@ -30,6 +32,23 @@
"vitest": "^4.1.11"
}
},
"node_modules/@agent-system/contracts": {
"version": "0.1.0",
"resolved": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9"
},
"node_modules/@agent-system/ui": {
"version": "0.1.0",
"resolved": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git#63d289e6bc15b701356cfe2d5b8740210fac0dcc",
"dependencies": {
"@agent-system/contracts": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9",
"dompurify": "^3.4.16",
"marked": "^15.0.7"
},
"peerDependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+4 -1
View File
@@ -6,11 +6,14 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test": "vitest run && npm run test:packages",
"test:packages": "node --test scripts/test-packages.mjs",
"preview": "vite preview",
"e2e": "playwright test"
},
"dependencies": {
"@agent-system/contracts": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9",
"@agent-system/ui": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git#63d289e6bc15b701356cfe2d5b8740210fac0dcc",
"docx-preview": "^0.4.1",
"dompurify": "^3.4.16",
"jszip": "^3.10.2",
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { test } from 'node:test'
const root = dirname(dirname(fileURLToPath(import.meta.url)))
test('the application builds from packed dependencies without UI or Core source siblings', () => {
const consumer = mkdtempSync(join(tmpdir(), 'agent-frontend-packages-'))
try {
// Only the application is copied. Source checkouts and node_modules never enter the fixture.
for (const path of ['src', 'test', 'index.html', 'vite.config.ts', 'tsconfig.json', 'package.json', 'package-lock.json']) {
cpSync(join(root, path), join(consumer, path), { recursive: true })
}
const pack = (name, envName) => {
if (process.env[envName]) return resolve(process.env[envName])
const [artifact] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', consumer], { cwd: join(root, 'node_modules/@agent-system', name), encoding: 'utf8' }))
return join(consumer, artifact.filename)
}
const manifest = JSON.parse(readFileSync(join(consumer, 'package.json'), 'utf8'))
const contracts = `file:${pack('contracts', 'CONTRACTS_TARBALL')}`
manifest.dependencies['@agent-system/contracts'] = contracts
manifest.dependencies['@agent-system/ui'] = `file:${pack('ui', 'UI_TARBALL')}`
manifest.overrides = { ...manifest.overrides, '@agent-system/contracts': contracts }
writeFileSync(join(consumer, 'package.json'), JSON.stringify(manifest))
// Overrides exist only in this disposable fixture. Formal manifests retain fixed Git commits.
execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--offline'], { cwd: consumer, stdio: 'inherit' })
execFileSync('npm', ['run', 'build'], { cwd: consumer, stdio: 'inherit' })
assert.ok(existsSync(join(consumer, 'dist/index.html')))
const copies = execFileSync('npm', ['ls', 'react', 'react-dom', '--parseable', '--all'], { cwd: consumer, encoding: 'utf8' }).trim().split('\n').filter(path => /node_modules\/(react|react-dom)$/.test(path))
assert.equal(copies.length, 2, 'the bundle must share the host React/ReactDOM peers')
} finally {
rmSync(consumer, { recursive: true, force: true })
}
})
+2 -402
View File
@@ -1,402 +1,2 @@
// 从 Agent-System-Core ts/types.ts 同步(make contracts 生成;Core 为权威)。
// Agent-System-Core 契约的 TypeScript 镜像。
// 线格式契约以 Go 类型(camelCase JSON 标签)与 testdata/golden/*.json 黄金样本为准;
// 本文件手工镜像同一契约,前端按未知 type/字段静默忽略(前向兼容原则)。
export type ID = string;
// ---- 领域模型 ----
export interface Project {
id: ID; owner?: ID; name: string; createdAt: string; updatedAt: string;
settings?: Record<string, unknown>;
}
export interface Conversation {
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
pinned?: boolean; archived?: boolean;
allowCrossRead?: boolean; crossPrivate?: boolean;
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
sessionNote?: string;
createdAt: string; updatedAt: string;
}
export type Role = 'user' | 'assistant' | 'system';
export type PartType =
| 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
| 'hitl' | 'plan' | 'error' | 'card' | 'check';
export interface Part {
type: PartType;
text?: string;
toolCall?: { id: string; name: string; arguments?: string; durationMs?: number };
toolResult?: { callId: string; status: 'ok' | 'error'; summary?: string; truncated?: boolean };
artifact?: { artifactId: ID; name: string; kind?: string };
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
plan?: PlanSnapshot;
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
error?: { code: string; message: string };
check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行
process?: boolean; // run 终态后折叠为摘要行
durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs
}
export interface Message {
id: ID; conversationId: ID; runId?: ID; role: Role;
parts: Part[]; createdAt: string;
excluded?: boolean;
variantGroup?: string;
variantIndex?: number;
}
export type RunKind = 'conversation' | 'subagent' | 'tool';
export type RunStatus =
| 'pending' | 'running' | 'waiting_hitl' | 'completed'
| 'failed' | 'cancelled' | 'interrupted';
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done';
export interface Usage {
promptTokens: number; completionTokens: number; totalTokens: number;
durationMs: number; toolCalls: number;
model?: string;
// N3 计费拆分(additive;旧记录缺省为 0):
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
lastPromptTokens?: number;
lastCompletionTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
export interface Run {
id: ID; parentRunId?: ID; conversationId: ID; projectId: ID; owner?: ID;
kind: RunKind; profileId?: ID; status: RunStatus; phase?: RunPhase;
agentName?: string; createdAt: string; endedAt?: string;
usage?: Usage; error?: { code: string; message: string };
}
export interface Artifact {
id: ID; projectId: ID; owner?: ID; name: string; kind: string; mime?: string;
size: number; sourceRun?: ID; createdAt: string; updatedAt: string;
}
export interface PlanSnapshot {
planId: ID; title: string;
steps: { id: string; text: string; status: 'pending' | 'in_progress' | 'done' | 'skipped'; agentRun?: ID }[];
}
export interface TodoList {
conversationId: ID;
items: { id: string; text: string; status: 'pending' | 'in_progress' | 'done' }[];
}
// ---- 流式事件 ----
export type StreamEventType =
| 'message.delta' | 'thinking.delta'
| 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result'
| 'run.phase' | 'run.check' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error'
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
| 'surface.updated';
export interface StreamEvent<P = unknown> {
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
type: StreamEventType; payload?: P;
}
// payload 形状(按 type 判别;未知字段忽略)
export interface MessageDeltaPayload { index: number; text: string }
export interface ToolCallDeltaPayload { index: number; callId?: string; name?: string; delta: string }
export interface ToolOutputPayload { callId: string; stream: 'stdout' | 'stderr'; text: string }
export interface ToolResultPayload { callId: string; status: string; summary?: string; truncated?: boolean; durationMs?: number }
export interface AgentSpawnPayload { agentRun: ID; name: string; task: string }
export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase }
export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string }
export interface HITLResolvedPayload { cardId: ID; answer?: string; kind: 'choice' | 'free' | 'timeout' | 'cancelled' }
export interface RunCompletedPayload {
usage: Usage
context?: ContextObservation
}
interface ContextSection {
name: string
estTokens: number
}
export interface ContextObservation {
items: string[]
truncations?: string[]
estChars: number
sections?: ContextSection[]
prefixHash?: string
}
export interface RunCheckPayload { passed: boolean; reason?: string }
export interface RunFailedPayload { code: string; message: string }
// ---- 收件箱卡片 ----
export type CardKind =
| 'progress' | 'confirm' | 'compare' | 'review-list'
| 'result' | 'error' | 'status';
export type CardStatus =
| 'active' | 'action_required' | 'done'
| 'resolved' | 'failed' | 'dismissed';
export interface ActivityCard {
id: ID; projectId: ID; conversationId?: ID;
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string;
}
export interface CardPayload { card: ActivityCard }
export interface CardStatusPayload { cardId: ID; status: CardStatus; tone?: string; note?: string }
export interface CardResolvedPayload { cardId: ID; actionId?: string; answer?: string }
// ---- HITL ----
export type HITLKind = 'choice' | 'input' | 'confirm' | 'form';
export interface HITLChoice {
id: string; label: string; description: string; impact: string; recommended?: boolean;
}
export interface HITLCard {
id: ID; runId: ID; conversationId: ID; question: string; kind: HITLKind;
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
detail?: string;
}
export interface HITLResponse {
cardId: ID; choiceId?: string; freeText?: string; formData?: unknown;
}
// ---- UI 词汇 ----
export type UINodeType =
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
export interface UINode {
type: UINodeType;
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
id?: string;
source?: string[];
title?: string;
items?: string[];
kv?: [string, string][];
fields?: UIField[];
markdown?: string;
docId?: ID;
actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger' }[];
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
children?: UINode[];
avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] };
progress?: { label?: string; value: number; tone?: string };
compare?: {
left: { title?: string; tone?: string; nodes?: UINode[] };
right: { title?: string; tone?: string; nodes?: UINode[] };
};
tree?: {
nodes: {
id: string; label: string; tone?: string; badge?: string; count?: number;
selected?: boolean; children?: unknown[];
}[];
};
table?: {
columns: { key: string; label: string }[];
rows: { id?: string; cells: [string, string][]; actions?: UINode['actions'] }[];
};
steps?: { current: number; steps: { label: string; status?: 'pending' | 'active' | 'done' | 'failed'; tone?: string }[] };
tabs?: { active?: string; tabs: { id: string; label: string; nodes?: UINode[] }[] };
timeline?: { items: { time?: string; title: string; tone?: string; markdown?: string; ref?: string }[] };
}
export interface UIField {
key: string; label: string;
type: 'text' | 'textarea' | 'number' | 'boolean' | 'select';
options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
}
// ---- 提示词库(prompt-library)----
export interface PromptTrigger {
kind: 'intent' | 'command' | 'attachment' | 'manual';
keywords?: string[]; command?: string;
}
export interface PromptModule {
id: string; layer: 'base' | 'env' | 'task' | 'agent' | 'tool' | 'aux' | 'style';
version: number; title: string; description?: string; priority?: number;
triggers?: PromptTrigger[]; requiresAttachments?: boolean;
body: string; outputContract?: string;
source?: 'builtin' | 'plugin' | 'user'; owner?: string;
basedOnVersion?: number; drifted?: boolean; disabled?: boolean;
/** 子代理 profile。只对 agent.sub.* 有意义;空字段表示沿用内置。 */
tools?: string[]; model?: string; maxTurns?: number;
}
export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; }
export interface PromptHitRecord { moduleId: string; layer: string; reason: string; injected: boolean; }
// ---- 画像 / 设置 ----
export interface AgentProfile {
id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string;
systemPrompt: string;
/** 提示词库模块引用(有序;空 = 默认 base+env 全集)。 */
promptModules?: string[];
model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
tools: { allow: string[]; deny?: string[]; confirm?: string[] };
context: {
maxHistoryMessages: number; summarizeBeyond?: boolean;
memoryBudgetPct?: number; knowledgeBudgetPct?: number; maxToolResultChars?: number;
};
hitl: { askOnAmbiguity: boolean; timeoutSec?: number | null };
budget: { maxTokens?: number | null; maxToolCalls?: number | null; maxTurns?: number | null };
}
export interface SettingDesc {
key: string; group: string; label: string; description?: string;
type: 'string' | 'number' | 'boolean' | 'enum' | 'secret';
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
scope?: 'system';
}
// ---- 插件 ----
export interface PluginManifest {
id: string; name: string; version: string; apiVersion: string;
requires?: { network?: string[] };
provides: {
tools?: string[]; workflows?: string[]; intents?: string[];
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
commands?: string[]; workspaces?: string[];
skills?: string[]; mcpServers?: string[]; knowledge?: string[];
parsers?: string[]; agents?: string[]; channels?: string[]; pages?: string[];
};
scope?: { tenants?: string[]; projects?: string[] };
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
}
export interface PluginPanel {
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
}
/** 模板占位符:{{input}}、{{slots.<key>}}、{{steps.<id>.output}}。 */
export interface WorkflowDef {
id: string; title: string; description?: string; intentId?: string;
/** 整条工作流的 critic 重试上限。 */
maxRetries?: number;
steps: WorkflowStep[];
}
export interface StepCritic {
criteria: string;
/** 本步重试上限;缺省 1 次。 */
maxRetries?: number;
}
export interface WorkflowStep {
id: string; title: string;
kind: 'prompt' | 'tool' | 'hitl' | 'artifact' | 'card' | 'script';
prompt?: string;
/** prompt 步骤的子代理 profile,缺省 general。 */
agent?: string;
tool?: string;
args?: unknown;
hitl?: { question: string; kind: HITLKind; choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string };
artifact?: string;
card?: { kind: CardKind; title: string; tone?: string; body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[] };
/** script 步骤在插件目录内的相对路径。 */
script?: string;
critic?: StepCritic;
}
export interface PluginWorkspace {
id: string; title: string; icon?: string; shortcut?: string;
badgeTool?: string; body: UINode[]; refreshTool?: string;
}
export interface PluginPageDecl {
id: string; title: string; module: string;
}
// ---- 摄取 / 检索 ----
export interface ParsedDocument {
meta: {
filename: string; mime?: string; parser: string; confidence?: number;
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
parsedAt: string;
};
blocks: {
type: 'heading' | 'para' | 'list' | 'table' | 'code' | 'image-ref';
level?: number; text?: string; items?: string[];
rows?: string[][]; ref?: string; confidence?: number;
}[];
warnings?: string[];
}
export interface RetrievalHit {
doc: { id: ID; owner?: ID; projectId?: ID; source: string; title: string; ref?: string; chunk: string; createdAt: string };
score: number; kind: 'fulltext' | 'vector' | 'plugin';
}
export interface UploadInfo {
artifactId: ID; filename: string; mime?: string; size: number;
parsed?: ParsedDocument;
}
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
export interface SurfaceCompat {
schemaVersion: number;
catalogVersion: string;
pluginVersion?: string;
}
export interface SurfaceIdentity {
surfaceId: ID;
owner: string;
projectId?: ID;
conversationId?: ID;
sourceRunId?: ID;
pluginId: string;
}
export interface SurfaceSpec {
title: string;
nodes: UINode[];
}
export interface SurfaceSnapshot {
identity: SurfaceIdentity;
compat: SurfaceCompat;
revision: number;
baseRevision?: number;
contextVersion?: string;
activationEpoch: number;
spec: SurfaceSpec;
semanticText?: string;
}
export interface SurfaceMeta {
surfaceId: ID;
title: string;
pluginId: string;
revision: number;
conversationId?: ID;
updatedAt: string;
}
// Core is the sole contract source; this compatibility import never copies types.
export type * from "@agent-system/contracts"
+4
View File
@@ -0,0 +1,4 @@
// Runtime/CSS resolution is the matching Vite alias. Types follow the public package export.
declare module '@ui' {
export * from '@agent-system/ui'
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Collapsible, UIRenderer } from '@ui'
import { UIRenderer as PackageRenderer } from '@agent-system/ui'
import type { UINode } from '@agent-system/contracts'
describe('versioned UI package consumption', () => {
it('existing @ui calls use the installed package and keep the canonical renderer behavior', () => {
const node: UINode = { type: 'kv', kv: [['Package consumer', 'available']] }
expect(UIRenderer).toBe(PackageRenderer)
const html = renderToStaticMarkup(createElement(Collapsible, { trigger: 'Details', defaultOpen: true }, createElement(UIRenderer, { nodes: [node] })))
expect(html).toContain('Package consumer')
expect(html).toContain('Details')
expect(html).toContain('mk-col')
})
})
+1 -9
View File
@@ -12,15 +12,7 @@
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
"@ui/*": ["../ui/src/*"],
"react": ["./node_modules/@types/react"],
"react-dom": ["./node_modules/@types/react-dom"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
}
"types": ["vite/client", "node"]
},
"include": ["src", "test"]
}
+3 -6
View File
@@ -1,15 +1,12 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// UI 组件库源码组合(modules/ui,源码级别名;见 Agent-System-Documents 提案 D29)。
const uiSrc = fileURLToPath(new URL('../ui/src', import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: [{ find: '@ui', replacement: uiSrc }],
// 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。
// Keep existing call sites while resolving components and CSS through package exports.
alias: [{ find: /^@ui(?=\/|$)/, replacement: '@agent-system/ui' }],
// UI declares React/ReactDOM peers; the host owns their single runtime copy.
dedupe: ['react', 'react-dom', 'marked', 'dompurify'],
},
server: {