This commit is contained in:
2026-08-29 13:32:57 +08:00
commit c56aa6e752
81 changed files with 14319 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
server/target/
web-ui/node_modules/
web-ui/dist/
sandbox/node_modules/
*.tsbuildinfo
data/
.idea/
*.iml
*.log
.env
.env.local
.DS_Store
dashscope_key.txt
deepseek_key.txt
__pycache__/
*.py[cod]
.env.*
!.env.example
test_data/

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# 智造申报 Agent
面向智能工厂申报的 Harness Agent 应用。用户创建企业项目并上传可用材料Agent 完成材料检验与建设规划确认后自主调用知识库、Skill 和受控工作区生成带 Word 原生批注的 DOCX 审阅稿。
## 技术栈
- Spring Boot 3.5.16、JDK 21、AgentScope Java 2.0.1、AG-UI
- Vue 3.5、Vite、Element Plus、Vue Element Plus X
- PostgreSQL 17、Flyway
## 本地启动
```bash
docker compose --profile build-only build agent-runtime
docker compose up -d postgres
mvn -q -f server/pom.xml spring-boot:run
npm --prefix client install
npm --prefix client run dev
```
打开 <http://127.0.0.1:5173>,本地默认账号为 `admin / admin123`
项目根目录的 `deepseek_key.txt``dashscope_key.txt` 分别供模型与百炼知识库使用模型、Skill 也可在页面内查看或配置。
## 验证
```bash
mvn -q -f server/pom.xml test
npm --prefix client run test -- --run
npm --prefix client run build
```
产品、数据库与验收设计见 [docs](docs/)。标准 Skill 源文件位于 [标准Skills](标准Skills/),仅作为开发素材保留,不参与应用启动同步。

25
compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
agent-runtime:
image: smart-factory-agent-runtime:0.1.0
build:
context: sandbox
profiles: ["build-only"]
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: smart_factory_agent
POSTGRES_USER: smart_factory
POSTGRES_PASSWORD: smart_factory
ports:
- "127.0.0.1:54330:5432"
volumes:
- smart-factory-pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U smart_factory -d smart_factory_agent"]
interval: 3s
timeout: 3s
retries: 20
volumes:
smart-factory-pg:

2
sandbox/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

41
sandbox/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:22-bookworm-slim AS node
FROM eclipse-temurin:21-jre-jammy AS java
FROM python:3.12-slim-bookworm
COPY --from=node /usr/local /usr/local
ENV JAVA_HOME=/opt/java/openjdk \
PATH=/opt/java/openjdk/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin \
NODE_PATH=/opt/agent-runtime/node_modules \
PLAYWRIGHT_BROWSERS_PATH=/opt/agent-runtime/ms-playwright \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /opt/agent-runtime
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& pip install --no-cache-dir \
defusedxml lxml "markitdown[pptx]" openpyxl pandas xlrd python-pptx pillow six \
pypdf pdfplumber reportlab pdf2image pytesseract pypdfium2
COPY --from=java /opt/java/openjdk /opt/java/openjdk
RUN ln -s /opt/java/openjdk/bin/java /usr/local/bin/java \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
adduser ca-certificates file findutils fonts-noto-cjk gawk git grep \
libreoffice-calc libreoffice-impress libreoffice-writer \
pandoc poppler-utils qpdf ripgrep sed tesseract-ocr tesseract-ocr-chi-sim unzip zip \
&& npx playwright install --with-deps chromium \
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agent \
&& mkdir -p /workspace \
&& chown agent:agent /workspace \
&& rm -rf /var/lib/apt/lists/*
COPY fonts-local.conf /etc/fonts/local.conf
RUN fc-cache -f
COPY --chown=agent:agent document_view.py ./
USER agent
WORKDIR /workspace
CMD ["sleep", "infinity"]

309
sandbox/document_view.py Normal file
View File

@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Render selected document pages, slides, sheets, ranges, or images for multimodal review."""
from __future__ import annotations
import json
import math
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
WORKSPACE = Path("/workspace").resolve()
ALLOWED_ROOTS = tuple((WORKSPACE / name).resolve() for name in ("inputs", "work", "references", "artifacts"))
MAX_PIXELS = 8_000_000
MAX_IMAGE_BYTES = 4 * 1024 * 1024
MAX_TOTAL_BYTES = 20 * 1024 * 1024
MAX_CELLS = 2_400
COMMAND_TIMEOUT_SECONDS = 45
TOTAL_TIMEOUT_SECONDS = 170
def safe_source(value: str) -> Path:
"""Resolve a workspace-relative source and reject traversal or unsupported roots."""
path = (WORKSPACE / value).resolve()
if not any(path == root or path.is_relative_to(root) for root in ALLOWED_ROOTS):
raise ValueError("文档路径必须位于 inputs、work、references 或 artifacts")
if not path.is_file():
raise ValueError("文档不存在")
return path
def run(command: list[str]) -> None:
"""Run a renderer command with bounded execution time and useful errors."""
result = subprocess.run(command, capture_output=True, text=True, timeout=COMMAND_TIMEOUT_SECONDS, check=False)
if result.returncode:
detail = (result.stderr or result.stdout or "渲染命令失败").strip()
raise RuntimeError(detail[-600:])
def office_pdf(source: Path, directory: Path) -> Path:
"""Convert an Office document to PDF with an isolated LibreOffice profile."""
profile = directory / "lo-profile"
run([
"libreoffice", "--headless", f"-env:UserInstallation=file://{profile}",
"--convert-to", "pdf", "--outdir", str(directory), str(source),
])
target = directory / f"{source.stem}.pdf"
if not target.is_file():
raise RuntimeError("LibreOffice 未生成 PDF")
return target
def render_pdf(source: Path, page: int, dpi: int, target: Path, fmt: str) -> None:
"""Render one one-based PDF page."""
output_type = "jpeg" if fmt == "jpeg" else "png"
stem = target.with_suffix("")
run([
"pdftoppm", "-f", str(page), "-l", str(page), "-singlefile", "-r", str(dpi),
f"-{output_type}", str(source), str(stem),
])
produced = stem.with_suffix(".jpg" if output_type == "jpeg" else ".png")
if not produced.is_file():
raise RuntimeError("未找到指定页,请检查页码")
produced.replace(target.with_suffix(produced.suffix))
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Load a CJK font available in the runtime image."""
names = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" if bold else
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for name in names:
if Path(name).is_file():
return ImageFont.truetype(name, size)
return ImageFont.load_default()
def cell_text(value: object) -> str:
"""Format a spreadsheet cell for visual review."""
if value is None:
return ""
text = str(value).replace("\r", " ").replace("\n", " ")
return text if len(text) <= 80 else f"{text[:79]}"
def draw_grid(values: list[list[object]], target: Path, title: str) -> None:
"""Render a bounded spreadsheet range as a readable table image."""
rows = len(values)
columns = max((len(row) for row in values), default=0)
if rows == 0 or columns == 0:
raise ValueError("所选工作表范围为空")
if rows * columns > MAX_CELLS:
raise ValueError(f"所选范围包含 {rows * columns} 个单元格,请拆分为多个较小范围")
body_font = font(18)
title_font = font(20, True)
widths = []
for column in range(columns):
longest = max((len(cell_text(row[column])) if column < len(row) else 0 for row in values), default=0)
widths.append(min(240, max(72, 18 + longest * 11)))
row_height = 38
title_height = 48
width = sum(widths) + 2
height = title_height + rows * row_height + 2
scale = min(1.0, math.sqrt(MAX_PIXELS / max(1, width * height)))
if scale < 1:
widths = [max(42, int(value * scale)) for value in widths]
row_height = max(24, int(row_height * scale))
title_height = max(34, int(title_height * scale))
body_font = font(max(12, int(18 * scale)))
title_font = font(max(14, int(20 * scale)), True)
width = sum(widths) + 2
height = title_height + rows * row_height + 2
image = Image.new("RGB", (width, height), "white")
painter = ImageDraw.Draw(image)
painter.rectangle((0, 0, width - 1, title_height), fill="#f1f5f9")
painter.text((12, 11), title, fill="#334155", font=title_font)
y = title_height
for row_index, row in enumerate(values):
x = 1
fill = "#f8fafc" if row_index % 2 else "#ffffff"
for column, cell_width in enumerate(widths):
painter.rectangle((x, y, x + cell_width, y + row_height), fill=fill, outline="#dbe3ed")
painter.text((x + 7, y + 8), cell_text(row[column] if column < len(row) else None),
fill="#253247", font=body_font)
x += cell_width
y += row_height
image.save(target, "PNG", optimize=True)
def render_sheet(source: Path, sheet: str | None, selected_range: str | None, target: Path) -> str:
"""Render a selected spreadsheet range and return the actual sheet/range label."""
if source.suffix.lower() == ".xls":
import pandas as pd
book = pd.ExcelFile(source)
sheet_name = sheet or book.sheet_names[0]
frame = pd.read_excel(source, sheet_name=sheet_name, header=None)
from openpyxl.utils.cell import range_boundaries
if selected_range:
min_col, min_row, max_col, max_row = range_boundaries(selected_range)
frame = frame.iloc[min_row - 1:max_row, min_col - 1:max_col]
else:
frame = frame.iloc[:60, :20]
selected_range = f"A1:{_column_name(max(1, frame.shape[1]))}{max(1, frame.shape[0])}"
values = frame.where(frame.notna(), None).values.tolist()
else:
from openpyxl import load_workbook
from openpyxl.utils.cell import range_boundaries
book = load_workbook(source, read_only=True, data_only=False)
sheet_name = sheet or book.sheetnames[0]
if sheet_name not in book.sheetnames:
raise ValueError(f"工作表不存在:{sheet_name}")
worksheet = book[sheet_name]
if selected_range:
min_col, min_row, max_col, max_row = range_boundaries(selected_range)
else:
min_col, min_row = 1, 1
max_col, max_row = min(20, worksheet.max_column), min(60, worksheet.max_row)
selected_range = f"A1:{_column_name(max_col)}{max_row}"
values = [
list(row)
for row in worksheet.iter_rows(
min_row=min_row,
max_row=max_row,
min_col=min_col,
max_col=max_col,
values_only=True,
)
]
book.close()
label = f"{sheet_name}!{selected_range}"
draw_grid(values, target, label)
return label
def _column_name(index: int) -> str:
"""Convert a one-based column index to an Excel column name."""
value = ""
while index:
index, remainder = divmod(index - 1, 26)
value = chr(65 + remainder) + value
return value
def normalize_image(path: Path, target: Path) -> None:
"""Normalize an uploaded image and bound its dimensions."""
with Image.open(path) as source:
image = ImageOps.exif_transpose(source).convert("RGB")
if image.width * image.height > MAX_PIXELS:
scale = math.sqrt(MAX_PIXELS / (image.width * image.height))
image = image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale))))
image.save(target, "JPEG", quality=88, optimize=True)
def bound_image(path: Path) -> Path:
"""Bound image pixels and bytes, converting oversized PNG files to JPEG."""
with Image.open(path) as source:
width, height = source.size
if width * height > MAX_PIXELS:
scale = math.sqrt(MAX_PIXELS / (width * height))
image = source.convert("RGB").resize((max(1, int(width * scale)), max(1, int(height * scale))))
replacement = path.with_suffix(".jpg")
image.save(replacement, "JPEG", quality=88, optimize=True)
path.unlink(missing_ok=True)
path = replacement
if path.stat().st_size > MAX_IMAGE_BYTES and path.suffix.lower() != ".jpg":
with Image.open(path) as source:
replacement = path.with_suffix(".jpg")
source.convert("RGB").save(replacement, "JPEG", quality=84, optimize=True)
path.unlink(missing_ok=True)
path = replacement
if path.stat().st_size > MAX_IMAGE_BYTES:
raise ValueError("渲染图片过大,请降低 DPI 或缩小工作表范围")
return path
def render(view: dict, index: int, output: Path) -> dict:
"""Render one requested view and return persisted metadata."""
source = safe_source(str(view.get("path", "")))
page = max(1, int(view.get("page") or 1))
dpi = min(220, max(72, int(view.get("dpi") or 144)))
fmt = "jpeg" if str(view.get("format", "png")).lower() in {"jpg", "jpeg"} else "png"
suffix = source.suffix.lower()
target = output / f"render-{index}.{'jpg' if fmt == 'jpeg' else 'png'}"
label = f"{page}"
if suffix == ".pdf":
render_pdf(source, page, dpi, target, fmt)
elif suffix in {".pptx", ".docx"}:
with tempfile.TemporaryDirectory(dir=output) as temporary:
converted = office_pdf(source, Path(temporary))
render_pdf(converted, page, dpi, target, fmt)
label = f"{page}" if suffix == ".pptx" else f"{page}"
elif suffix in {".xls", ".xlsx"}:
target = target.with_suffix(".png")
label = render_sheet(source, view.get("sheet"), view.get("range"), target)
elif suffix in {".png", ".jpg", ".jpeg", ".webp"}:
target = target.with_suffix(".jpg")
normalize_image(source, target)
label = "原图"
else:
raise ValueError(f"document_view 暂不支持 {suffix or '无扩展名'} 文件")
target = bound_image(target)
with Image.open(target) as image:
width, height = image.size
return {
"index": index,
"sourcePath": str(source.relative_to(WORKSPACE)),
"path": str(target.relative_to(WORKSPACE)),
"label": label,
"mediaType": "image/jpeg" if target.suffix.lower() == ".jpg" else "image/png",
"width": width,
"height": height,
"sizeBytes": target.stat().st_size,
}
def main(request_path: Path, result_path: Path) -> int:
"""Process all requested views and persist partial successes plus errors."""
request_path = request_path.resolve()
result_path = result_path.resolve()
request = json.loads(request_path.read_text(encoding="utf-8"))
views = request.get("views")
if not isinstance(views, list) or not views:
raise ValueError("views 至少包含一个查看请求")
output = result_path.parent
output.mkdir(parents=True, exist_ok=True)
started = time.monotonic()
images: list[dict] = []
errors: list[dict] = []
total_bytes = 0
for index, view in enumerate(views, 1):
if time.monotonic() - started > TOTAL_TIMEOUT_SECONDS:
errors.append({"index": index, "error": "本次渲染已达到时间上限,请减少页面后重试"})
continue
try:
image = render(view, index, output)
total_bytes += image["sizeBytes"]
if total_bytes > MAX_TOTAL_BYTES:
Path(WORKSPACE / image["path"]).unlink(missing_ok=True)
total_bytes -= image["sizeBytes"]
raise ValueError("本次渲染图片总量过大,请拆分调用")
images.append(image)
except Exception as exception: # Each failed view is returned to the Agent as an observation.
errors.append({"index": index, "path": str(view.get("path", "")), "error": str(exception)[:800]})
result_path.write_text(json.dumps({"images": images, "errors": errors}, ensure_ascii=False), encoding="utf-8")
return 0 if images else 2
if __name__ == "__main__":
if len(sys.argv) != 3:
print("usage: document_view.py request.json result.json", file=sys.stderr)
raise SystemExit(2)
try:
raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2])))
except Exception as failure:
Path(sys.argv[2]).write_text(
json.dumps({"images": [], "errors": [{"index": 0, "error": str(failure)[:800]}]}, ensure_ascii=False),
encoding="utf-8",
)
raise SystemExit(2)

20
sandbox/fonts-local.conf Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<alias binding="strong">
<family>SimSun</family>
<prefer><family>Noto Serif CJK SC</family></prefer>
</alias>
<alias binding="strong">
<family>宋体</family>
<prefer><family>Noto Serif CJK SC</family></prefer>
</alias>
<alias binding="strong">
<family>SimHei</family>
<prefer><family>Noto Sans CJK SC</family></prefer>
</alias>
<alias binding="strong">
<family>黑体</family>
<prefer><family>Noto Sans CJK SC</family></prefer>
</alias>
</fontconfig>

974
sandbox/package-lock.json generated Normal file
View File

@@ -0,0 +1,974 @@
{
"name": "smart-factory-agent-runtime",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smart-factory-agent-runtime",
"version": "0.1.0",
"dependencies": {
"docx": "^9.5.1",
"pdf-lib": "^1.17.1",
"playwright": "^1.62.1",
"pptxgenjs": "^4.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"sharp": "^0.35.4"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
}
}
}

15
sandbox/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "smart-factory-agent-runtime",
"private": true,
"version": "0.1.0",
"dependencies": {
"docx": "^9.5.1",
"pdf-lib": "^1.17.1",
"playwright": "^1.62.1",
"pptxgenjs": "^4.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"sharp": "^0.35.4"
}
}

110
server/pom.xml Normal file
View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>cn.alphaline</groupId>
<artifactId>ManuAgent-server</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>Smart Factory Approval Agent</name>
<properties>
<java.version>21</java.version>
<agentscope.version>2.0.1</agentscope.version>
<tika.version>3.2.3</tika.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>${tika.version}</version>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-harness</artifactId>
<version>${agentscope.version}</version>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-openai</artifactId>
<version>${agentscope.version}</version>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-agui</artifactId>
<version>${agentscope.version}</version>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-skill-postgresql-repository</artifactId>
<version>${agentscope.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,24 @@
package cn.alphaline.smartfactory;
import cn.alphaline.smartfactory.config.AppProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/**
* 智造申报 Agent 服务入口。
*/
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class SmartFactoryApplication {
/**
* 启动 Spring Boot 应用。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
SpringApplication.run(SmartFactoryApplication.class, args);
}
}

View File

@@ -0,0 +1,141 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.databind.JsonNode;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* 提供可恢复的 Agent Run 与 AG-UI 事件流接口。
*/
@RestController
@RequestMapping("/api/projects/{projectId}")
public class AgentController {
private final AgentRunService runService;
private final AgentEventService eventService;
/**
* 创建 Agent 控制器。
*
* @param runService Run 服务
* @param eventService 事件服务
*/
public AgentController(AgentRunService runService, AgentEventService eventService) {
this.runService = runService;
this.eventService = eventService;
}
/**
* 启动材料检验与规划 Run。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return Run
*/
@PostMapping("/runs/material-check")
public AgentRunService.RunView startMaterialCheck(@PathVariable UUID projectId, Principal principal) {
return runService.startMaterialCheck(projectId, principal);
}
/**
* 确认材料检验结果并进入规划生成。
*
* @param projectId 项目 ID
* @param response 材料缺口处理结果
* @param principal 当前用户
* @return 规划 Run
*/
@PostMapping("/material/confirm")
public AgentRunService.RunView confirmMaterials(
@PathVariable UUID projectId,
@RequestBody JsonNode response,
Principal principal) {
return runService.confirmMaterials(projectId, response, principal);
}
/**
* 启动确认后的自动编写 Run。
*
* @param projectId 项目 ID
* @return Run
*/
@PostMapping("/runs/writing")
public AgentRunService.RunView startWriting(@PathVariable UUID projectId) {
return runService.startWriting(projectId);
}
/**
* 立即停止当前 Agent Run。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 已中断 Run
*/
@PostMapping("/runs/stop")
public AgentRunService.RunView stop(@PathVariable UUID projectId, Principal principal) {
return runService.stop(projectId, principal);
}
/**
* 从已中断位置继续 Agent Run。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 新恢复 Run
*/
@PostMapping("/runs/resume")
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) {
return runService.resume(projectId, principal);
}
/**
* 返回最近 Run。
*
* @param projectId 项目 ID
* @return 最近 Run不存在时返回 204
*/
@GetMapping("/runs/latest")
public ResponseEntity<AgentRunService.RunView> latest(@PathVariable UUID projectId) {
AgentRunService.RunView run = runService.latest(projectId);
return run == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(run);
}
/**
* 通过游标读取持久化事件。
*
* @param projectId 项目 ID
* @param after 最后已接收事件序号
* @return 增量事件
*/
@GetMapping("/events")
public List<AgentEventService.EventView> events(
@PathVariable UUID projectId,
@RequestParam(defaultValue = "0") long after) {
return eventService.listAfter(projectId, after, 1000);
}
/**
* 以 NDJSON 持续输出新增事件;页面刷新可携带游标恢复。
*
* @param projectId 项目 ID
* @param after 最后已接收事件序号
* @return 持续增量事件流
*/
@GetMapping(path = "/events/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<AgentEventService.EventView> stream(
@PathVariable UUID projectId,
@RequestParam(defaultValue = "0") long after) {
return eventService.streamAfter(projectId, after);
}
}

View File

@@ -0,0 +1,192 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
/**
* 持久化并查询项目级 AG-UI 事件。
*/
@Service
public class AgentEventService {
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
/**
* 创建事件服务。
*
* @param jdbc JDBC 客户端
* @param objectMapper JSON 映射器
*/
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
/**
* 先持久化一个事件,再将其返回给流式接口。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param eventType AG-UI 事件类型
* @param payload 事件负载
* @return 已分配全局序号的事件
*/
public EventView append(UUID projectId, UUID runId, String eventType, Object payload) {
JsonNode value = objectMapper.valueToTree(payload);
ObjectNode object = value.isObject()
? (ObjectNode) value
: objectMapper.createObjectNode().set("value", value);
EventView event = jdbc.sql("""
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb))
RETURNING id, project_id, run_id, event_type, payload, created_at
""")
.param("projectId", projectId)
.param("runId", runId)
.param("eventType", eventType)
.param("eventId", UUID.randomUUID().toString())
.param("payload", object.toString())
.query(this::mapEvent)
.single();
publishAfterCommit(event);
return event;
}
/**
* 按游标查询增量事件。
*
* @param projectId 项目 ID
* @param afterId 排除的最后事件序号
* @param limit 最大返回数量
* @return 有序事件
*/
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
return jdbc.sql("""
SELECT id, project_id, run_id, event_type, payload, created_at
FROM app.agent_event
WHERE project_id = :projectId AND id > :afterId
ORDER BY id
LIMIT :limit
""")
.param("projectId", projectId)
.param("afterId", Math.max(0, afterId))
.param("limit", Math.clamp(limit, 1, 1000))
.query(this::mapEvent)
.list();
}
/**
* 先回放数据库增量,再持续推送当前进程产生的新事件。
*
* @param projectId 项目 ID
* @param afterId 排除的最后事件序号
* @return 带轻量心跳的事件流
*/
public Flux<EventView> streamAfter(UUID projectId, long afterId) {
return Flux.defer(() -> {
AtomicLong cursor = new AtomicLong(Math.max(0, afterId));
Sinks.Many<EventView> sink = liveStreams.computeIfAbsent(
projectId, ignored -> Sinks.many().replay().limit(2_048));
Mono<List<EventView>> first = queryBatch(projectId, cursor.get());
Flux<EventView> backlog = first
.expand(batch -> batch.size() == 1_000
? queryBatch(projectId, batch.getLast().id())
: Mono.empty())
.flatMapIterable(batch -> batch);
Flux<EventView> events = Flux.concat(backlog, sink.asFlux())
.filter(event -> event.id() > cursor.get())
.doOnNext(event -> cursor.set(event.id()));
Flux<EventView> heartbeat = Flux.interval(java.time.Duration.ofSeconds(15))
.map(tick -> new EventView(
0,
projectId,
null,
"HEARTBEAT",
objectMapper.createObjectNode(),
OffsetDateTime.now()));
return Flux.merge(events, heartbeat)
.doFinally(signal -> {
if (sink.currentSubscriberCount() == 0) {
liveStreams.remove(projectId, sink);
}
});
});
}
private Mono<List<EventView>> queryBatch(UUID projectId, long afterId) {
return Mono.fromCallable(() -> listAfter(projectId, afterId, 1_000))
.subscribeOn(Schedulers.boundedElastic());
}
private void publishAfterCommit(EventView event) {
Runnable publish = () -> {
Sinks.Many<EventView> sink = liveStreams.get(event.projectId());
if (sink != null) {
Sinks.EmitResult result = sink.tryEmitNext(event);
if (result == Sinks.EmitResult.FAIL_NON_SERIALIZED) {
sink.emitNext(event, Sinks.EmitFailureHandler.busyLooping(java.time.Duration.ofMillis(100)));
}
}
};
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publish.run();
}
});
} else {
publish.run();
}
}
private EventView mapEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
try {
return new EventView(
rs.getLong("id"),
rs.getObject("project_id", UUID.class),
rs.getObject("run_id", UUID.class),
rs.getString("event_type"),
objectMapper.readTree(rs.getString("payload")),
rs.getObject("created_at", OffsetDateTime.class));
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception);
}
}
/**
* 前端可恢复事件视图。
*
* @param id 项目全局事件序号
* @param projectId 项目 ID
* @param runId Run ID心跳为空
* @param type 事件类型
* @param payload AG-UI 事件负载
* @param createdAt 产生时间
*/
public record EventView(
long id,
UUID projectId,
UUID runId,
String type,
JsonNode payload,
OffsetDateTime createdAt) {
}
}

View File

@@ -0,0 +1,263 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.project.ProjectFileService;
import cn.alphaline.smartfactory.project.ProjectService;
import cn.alphaline.smartfactory.skill.SkillService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.model.ModelHttpException;
import io.agentscope.core.model.transport.HttpTransportException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
/**
* 运行 Harness Agent并把 AG-UI 增量转换为可恢复事件。
*/
@Service
public class AgentExecutionService {
private static final Pattern HOST_PATH = Pattern.compile("/Users/[^\\s\"')]+");
private static final Pattern API_KEY = Pattern.compile("sk-[A-Za-z0-9_-]{8,}");
static final int MAX_MODEL_RECONNECTS = 5;
private final ObjectMapper objectMapper;
private final AgentFactory agentFactory;
private final AgentEventService eventService;
private final ProjectFileService fileService;
private final SkillService skillService;
/**
* 创建 Agent 执行服务。
*
* @param objectMapper JSON 映射器
* @param agentFactory Agent 工厂
* @param eventService 事件服务
* @param fileService 工作区服务
* @param skillService Skill 服务
*/
public AgentExecutionService(
ObjectMapper objectMapper,
AgentFactory agentFactory,
AgentEventService eventService,
ProjectFileService fileService,
SkillService skillService) {
this.objectMapper = objectMapper;
this.agentFactory = agentFactory;
this.eventService = eventService;
this.fileService = fileService;
this.skillService = skillService;
}
/**
* 执行一个可停止、可重连的 Agent 流。
*
* @param project 当前项目
* @param run 当前 Run
* @param prompt 本轮提示词
* @param stopSignal 用户停止信号
* @param ensureRunning 终态检查
* @param interrupted 中断状态查询
*/
public void execute(
ProjectService.ProjectView project,
AgentRunService.RunView run,
String prompt,
Mono<Void> stopSignal,
Runnable ensureRunning,
BooleanSupplier interrupted) {
EventAccumulator accumulator = new EventAccumulator(project.id(), run.id());
for (int reconnects = 0; ; reconnects++) {
ensureRunning.run();
String attemptPrompt = reconnects == 0 ? prompt : """
模型连接刚刚中断。请恢复同一线程的会话状态,读取 MEMORY.md 和工作区已有成果,
检查未完成的输出后从中断处继续;复用已经完成的工具结果,不要重复已完成操作。
""";
RunAgentInput input = RunAgentInput.builder()
.threadId(project.threadId())
.runId(run.id().toString())
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
.build();
try (AgentFactory.AgentHandle handle = agentFactory.create(project.id(), skillService.enabledNames())) {
handle.adapter().run(input)
.takeUntilOther(stopSignal)
.bufferTimeout(64, Duration.ofMillis(120))
.doOnNext(accumulator::accept)
.blockLast();
accumulator.flush();
ensureRunning.run();
return;
} catch (RuntimeException exception) {
accumulator.flush();
if (interrupted.getAsBoolean()) {
throw new RunInterruptedException();
}
if (reconnects >= MAX_MODEL_RECONNECTS || !isRetryableModelFailure(exception)) {
throw exception;
}
int attempt = reconnects + 1;
eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of(
"attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS));
pauseBeforeReconnect(attempt, interrupted);
}
}
}
/**
* 判断异常链是否属于可重试的模型连接故障。
*
* @param failure 模型调用异常
* @return 是否允许重连
*/
static boolean isRetryableModelFailure(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof HttpTransportException transport && transport.isRetryable()) {
return true;
}
if (current instanceof ModelHttpException http && http.isRetryableHttpStatus()) {
return true;
}
if (current.getCause() == current) {
break;
}
current = current.getCause();
}
return false;
}
private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted) {
try {
Thread.sleep(Math.min(8_000L, 500L << (attempt - 1)));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
if (interrupted.getAsBoolean()) {
throw new RunInterruptedException();
}
throw new IllegalStateException("模型重连等待被中断", exception);
}
}
/**
* 标识正常的用户中断。
*/
static final class RunInterruptedException extends RuntimeException {
}
/**
* 跨 Reactor 批次合并连续 token并过滤 Adapter 自带的重复生命周期事件。
*/
private final class EventAccumulator {
private final UUID projectId;
private final UUID runId;
private ObjectNode pending;
private String pendingType;
private String pendingKey;
private String pendingField;
private long lastFlushNanos = System.nanoTime();
private EventAccumulator(UUID projectId, UUID runId) {
this.projectId = projectId;
this.runId = runId;
}
private void accept(List<AguiEvent> batch) {
for (AguiEvent event : batch) {
accept(event);
}
if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) {
flush();
}
}
private void accept(AguiEvent event) {
String type = event.getType().name();
if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED") || type.equals("RUN_ERROR")) {
return;
}
ObjectNode payload = (ObjectNode) sanitize(
objectMapper.valueToTree(event), fileService.projectRoot(projectId).toString());
if (type.equals("TOOL_CALL_RESULT") && payload.path("content").isTextual()) {
payload.put("content", stripInlineImageData(payload.path("content").asText()));
}
String field = switch (type) {
case "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK",
"REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_CHUNK" ->
payload.has("delta") ? "delta" : "content";
case "TOOL_CALL_ARGS", "TOOL_CALL_CHUNK" -> payload.has("delta") ? "delta" : "args";
default -> null;
};
String key = payload.path("messageId").asText(payload.path("toolCallId").asText(""));
if (field != null && pending != null && type.equals(pendingType)
&& key.equals(pendingKey) && field.equals(pendingField)) {
pending.put(field, pending.path(field).asText() + payload.path(field).asText());
return;
}
flush();
if (field == null) {
eventService.append(projectId, runId, type, payload);
} else {
pending = payload;
pendingType = type;
pendingKey = key;
pendingField = field;
}
}
private JsonNode sanitize(JsonNode node, String workspaceRoot) {
if (node.isTextual()) {
String text = node.textValue().replace(workspaceRoot, "工作区");
text = HOST_PATH.matcher(text).replaceAll("内部路径");
return objectMapper.getNodeFactory().textNode(API_KEY.matcher(text).replaceAll("已隐藏凭证"));
}
if (node instanceof ObjectNode object) {
object.properties().forEach(entry -> object.set(
entry.getKey(), sanitize(entry.getValue(), workspaceRoot)));
} else if (node instanceof ArrayNode array) {
for (int index = 0; index < array.size(); index++) {
array.set(index, sanitize(array.get(index), workspaceRoot));
}
}
return node;
}
private void flush() {
if (pending != null) {
eventService.append(projectId, runId, pendingType, pending);
pending = null;
pendingType = null;
pendingKey = null;
pendingField = null;
}
lastFlushNanos = System.nanoTime();
}
}
/**
* 从持久化 AG-UI 工具结果中移除已经传给模型的 Base64 图片,保留结构化文本元数据。
*
* @param content AG-UI 工具结果文本
* @return 适合数据库与浏览器恢复的精简结果
*/
static String stripInlineImageData(String content) {
int imageStart = content.indexOf("\n{\"type\":\"image\"");
if (content.startsWith("document_view_result=") && imageStart > 0) {
return content.substring(0, imageStart);
}
return content.lines()
.filter(line -> !((line.contains("\"mediaType\"") || line.contains("\"media_type\""))
&& line.contains("\"data\"")))
.collect(java.util.stream.Collectors.joining("\n"));
}
}

View File

@@ -0,0 +1,320 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.config.AppProperties;
import cn.alphaline.smartfactory.model.ModelService;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAdapterConfig;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.AgentSkillRepository;
import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.extensions.model.openai.OpenAIChatModel;
import io.agentscope.harness.agent.IsolationScope;
import io.agentscope.harness.agent.HarnessAgent;
import io.agentscope.harness.agent.memory.compaction.CompactionConfig;
import io.agentscope.harness.agent.memory.compaction.ToolResultEvictionConfig;
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerFilesystemSpec;
import io.agentscope.harness.agent.sandbox.layout.BindMountEntry;
import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry;
import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
/**
* 按当前模型、Skill 和项目工作区创建短生命周期 Harness Agent。
*/
@Component
public class AgentFactory {
private final ModelService modelService;
private final PostgresSkillRepository skillRepository;
private final ProjectFileService fileService;
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final String systemPrompt;
private final String compactionPrompt;
/**
* 创建 Agent 工厂。
*
* @param modelService 模型配置服务
* @param skillRepository AgentScope Skill 仓库
* @param fileService 项目工作区服务
* @param properties 应用配置
* @param objectMapper JSON 映射器
*/
public AgentFactory(
ModelService modelService,
PostgresSkillRepository skillRepository,
ProjectFileService fileService,
AppProperties properties,
ObjectMapper objectMapper) {
this.modelService = modelService;
this.skillRepository = skillRepository;
this.fileService = fileService;
this.properties = properties;
this.objectMapper = objectMapper;
this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词");
this.compactionPrompt = readPrompt("prompts/smart-factory-compaction.md", "上下文压缩提示词");
}
/**
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
*
* @param projectId 项目 ID
* @param enabledSkills 当前启用 Skill
* @return 需要在流结束后关闭的 Agent 句柄
*/
public AgentHandle create(UUID projectId, String[] enabledSkills) {
ModelService.ModelSecret model = modelService.defaultModelSecret();
OpenAIChatModel chatModel = OpenAIChatModel.builder()
.apiKey(model.apiKey())
.baseUrl(model.baseUrl())
.modelName(model.modelId())
.stream(true)
.build();
fileService.ensureWorkspace(projectId);
Path projectRoot = fileService.projectRoot(projectId);
Map<String, String> environment = new HashMap<>();
environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile()));
environment.put("PATH", "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin");
environment.put("NODE_PATH", "/opt/agent-runtime/node_modules");
environment.put("SKILL_SESSION_ID", "project-" + projectId);
WorkspaceSpec workspace = new WorkspaceSpec();
Map<String, WorkspaceEntry> entries = new LinkedHashMap<>();
entries.put("inputs", mount(projectRoot.resolve("inputs"), true));
entries.put("work", mount(projectRoot.resolve("work"), false));
entries.put("references", mount(projectRoot.resolve("references"), false));
entries.put("artifacts", mount(projectRoot.resolve("work/candidates"), false));
workspace.setEntries(entries);
workspace.setEnvironment(environment);
Path snapshotRoot = properties.dataRoot().toAbsolutePath().normalize().resolve("sandbox-snapshots");
try {
Files.createDirectories(snapshotRoot);
} catch (IOException exception) {
throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception);
}
DockerFilesystemSpec filesystem = new DockerFilesystemSpec()
.image(properties.sandboxImage())
.workspaceRoot("/workspace")
.environment(environment)
.memorySizeBytes(2L * 1024 * 1024 * 1024)
.cpuCount(2L)
.exposedPorts()
.network(properties.sandboxNetwork())
.additionalRunArgs(
"--pids-limit=256",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--stop-signal=SIGKILL")
.snapshotSpec(new LocalSnapshotSpec(snapshotRoot))
.workspaceSpec(workspace);
filesystem.isolationScope(IsolationScope.SESSION);
CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt);
Toolkit toolkit = new Toolkit();
DocumentViewTool documentView = new DocumentViewTool(objectMapper);
toolkit.registerTool(documentView);
HarnessAgent agent = HarnessAgent.builder()
.name("smart-factory-agent")
.description("智能工厂申报书规划、编写与评审 Agent")
.sysPrompt(systemPrompt)
.model(chatModel)
.toolkit(toolkit)
.workspace(projectRoot)
.filesystem(filesystem)
.skillRepository(new EnabledSkillRepository(skillRepository, Set.copyOf(Arrays.asList(enabledSkills))))
.compaction(compaction)
.toolResultEviction(ToolResultEvictionConfig.defaults())
.maxContextTokens(model.contextWindow())
.maxIters(96)
.enableAgentTracingLog(false)
.disableSubagents()
.build();
agent.getToolkit().registerTool(new PagedReadFileTool(
agent.getWorkspaceManager().getFilesystem(),
WorkspacePathNormalizer.of("/workspace"),
objectMapper));
documentView.bind(agent);
AguiAdapterConfig config = AguiAdapterConfig.builder()
.enableReasoning(true)
.emitToolCallArgs(true)
.emitStateEvents(false)
.runTimeout(properties.runTimeout())
.defaultAgentId("smart-factory-agent")
.build();
return new AgentHandle(new AguiAgentAdapter(agent, config), agent);
}
/**
* 按模型窗口构造仅由 Token 触发的压缩配置。
*
* @param contextWindow 模型上下文窗口
* @param summaryPrompt 压缩摘要约束
* @return AgentScope 压缩配置
*/
static CompactionConfig compactionFor(int contextWindow, String summaryPrompt) {
int triggerTokens = Math.max(1, (int) Math.floor(contextWindow * 0.90d));
int keepTokensMin = Math.max(1_024, Math.min(4_000, contextWindow / 8));
int keepTokensMax = Math.max(keepTokensMin, Math.min(16_000, contextWindow / 5));
return CompactionConfig.builder()
.triggerMessages(0)
.triggerTokens(triggerTokens)
.reserved(Math.max(1_024, contextWindow / 10))
.keepTokensMin(keepTokensMin)
.keepTokensMax(keepTokensMax)
.keepTokensRatio(0.18d)
.summaryPrompt(summaryPrompt)
.flushBeforeCompact(true)
.offloadBeforeCompact(true)
.build();
}
/**
* 将 AgentScope 默认的 name_source Skill ID 规范化为业务名称,同时保留完整 Skill 信息。
*
* @param skill 仓库返回的 Skill
* @return 使用业务名称作为调用 ID 的 Skill
*/
static AgentSkill canonicalSkill(AgentSkill skill) {
return new AgentSkill(
skill.getMetadata(),
skill.getSkillContent(),
skill.getResources(),
skill.getSource(),
skill.getOriginDir().orElse(null)) {
/** {@inheritDoc} */
@Override
public String getSkillId() {
return getName();
}
};
}
private BindMountEntry mount(Path hostPath, boolean readOnly) {
BindMountEntry entry = new BindMountEntry();
entry.setHostPath(hostPath.toAbsolutePath().normalize().toString());
entry.setReadOnly(readOnly);
return entry;
}
private String readPrompt(String path, String label) {
try {
return new ClassPathResource(path)
.getContentAsString(StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("无法读取" + label, exception);
}
}
private String readOptionalKey(java.nio.file.Path path) {
try {
return Files.isRegularFile(path) ? Files.readString(path, StandardCharsets.UTF_8).trim() : "";
} catch (IOException exception) {
throw new IllegalStateException("无法读取百炼知识库 Key", exception);
}
}
/**
* 绑定 AG-UI 适配器与其拥有的沙箱 Agent 生命周期。
*
* @param adapter AG-UI 适配器
* @param agent Harness Agent
*/
public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent) implements AutoCloseable {
/**
* 结束 Agent 并释放 Docker 沙箱资源。
*/
@Override
public void close() {
agent.close();
}
}
/**
* 只向 Agent 暴露管理员启用的 Skill所有写操作继续委托原仓库。
*/
private static final class EnabledSkillRepository implements AgentSkillRepository {
private final AgentSkillRepository delegate;
private final Set<String> enabled;
private EnabledSkillRepository(AgentSkillRepository delegate, Set<String> enabled) {
this.delegate = delegate;
this.enabled = enabled;
}
@Override
public AgentSkill getSkill(String name) {
AgentSkill skill = enabled.contains(name) ? delegate.getSkill(name) : null;
return skill == null ? null : canonicalSkill(skill);
}
@Override
public List<String> getAllSkillNames() {
return delegate.getAllSkillNames().stream().filter(enabled::contains).toList();
}
@Override
public List<AgentSkill> getAllSkills() {
return delegate.getAllSkills().stream()
.filter(skill -> enabled.contains(skill.getName()))
.map(AgentFactory::canonicalSkill)
.toList();
}
@Override
public boolean save(List<AgentSkill> skills, boolean overwrite) {
return delegate.save(skills, overwrite);
}
@Override
public boolean delete(String name) {
return delegate.delete(name);
}
@Override
public boolean skillExists(String name) {
return enabled.contains(name) && delegate.skillExists(name);
}
@Override
public AgentSkillRepositoryInfo getRepositoryInfo() {
return delegate.getRepositoryInfo();
}
@Override
public String getSource() {
return delegate.getSource();
}
@Override
public void setWriteable(boolean writeable) {
delegate.setWriteable(writeable);
}
@Override
public boolean isWriteable() {
return delegate.isWriteable();
}
}
}

View File

@@ -0,0 +1,179 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.IntSummaryStatistics;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
/**
* 读取并校验 Agent 交给业务流程的结构化文件。
*/
@Service
public class AgentOutputService {
private static final Pattern YEAR_RANGE = Pattern.compile("(20\\d{2})\\D+(20\\d{2})");
private static final Pattern NUMBER = Pattern.compile("(\\d+)");
private final ObjectMapper objectMapper;
private final ProjectFileService fileService;
/**
* 创建 Agent 输出服务。
*
* @param objectMapper JSON 映射器
* @param fileService 工作区服务
*/
public AgentOutputService(ObjectMapper objectMapper, ProjectFileService fileService) {
this.objectMapper = objectMapper;
this.fileService = fileService;
}
/**
* 读取并校验材料检验结果。
*
* @param projectId 项目 ID
* @return 材料检验对象
* @throws IOException 文件无法读取时抛出
*/
public ObjectNode readMaterialCheck(UUID projectId) throws IOException {
Path path = fileService.safeProjectPath(projectId, "work/facts/material-check.json");
if (!Files.isRegularFile(path)) {
throw new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"MATERIAL_CHECK_MISSING",
"Agent 未生成材料检验结果,请重试");
}
JsonNode value = objectMapper.readTree(path.toFile());
if (!(value instanceof ObjectNode report)
|| !hasText(report, "summary")
|| !report.path("completeness").canConvertToInt()
|| !report.path("confirmedFacts").isArray()
|| !report.path("missingItems").isArray()) {
throw new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"MATERIAL_CHECK_INVALID",
"Agent 生成的材料检验结果结构不完整,请重试");
}
return report;
}
/**
* 读取并校验 Agent 建议规划。
*
* @param projectId 项目 ID
* @return 建设规划对象
* @throws IOException 文件无法读取时抛出
*/
public ObjectNode readProposedPlan(UUID projectId) throws IOException {
Path path = fileService.safeProjectPath(projectId, "work/plans/proposed-plan.json");
if (!Files.isRegularFile(path)) {
throw new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"PLAN_OUTPUT_MISSING",
"Agent 未生成结构化建设规划,请重试材料检验");
}
JsonNode value = objectMapper.readTree(path.toFile());
if (!(value instanceof ObjectNode plan)) {
throw invalidPlan();
}
normalizePlanningYears(plan);
if (!hasText(plan, "coreDirection")
|| !hasText(plan, "collaborationDirection")
|| !hasText(plan, "factoryName")
|| !plan.path("planningYears").canConvertToInt()
|| !hasText(plan, "investmentRange")
|| !hasText(plan, "applicationLevel")
|| !plan.path("scenarios").isArray()
|| plan.path("scenarios").isEmpty()
|| !plan.path("aiScenarioCount").canConvertToInt()
|| !plan.path("assumptions").isArray()) {
throw invalidPlan();
}
plan.put("scenarioCount", plan.path("scenarios").size());
return plan;
}
/**
* 校验用户确认后的规划满足编写最小结构。
*
* @param plan 用户确认规划
*/
public void validateConfirmedPlan(JsonNode plan) {
if (plan == null || !plan.isObject()
|| !hasText(plan, "coreDirection")
|| !hasText(plan, "collaborationDirection")
|| !hasText(plan, "factoryName")
|| !plan.path("planningYears").canConvertToInt()
|| plan.path("planningYears").asInt() < 1
|| !hasText(plan, "investmentRange")
|| !plan.path("scenarioCount").canConvertToInt()
|| plan.path("scenarioCount").asInt() < 1
|| !plan.path("aiScenarioCount").canConvertToInt()
|| plan.path("aiScenarioCount").asInt() < 0
|| plan.path("aiScenarioCount").asInt() > plan.path("scenarioCount").asInt()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PLAN_INVALID", "请完整填写并检查建设规划");
}
}
/**
* 将模型可能输出的年份数组或文本区间归一化为规划年数。
*
* @param plan 待归一化的规划对象
*/
static void normalizePlanningYears(ObjectNode plan) {
JsonNode value = plan.path("planningYears");
if (value.canConvertToInt()) {
return;
}
if (value.isArray()) {
IntSummaryStatistics years = new IntSummaryStatistics();
value.forEach(year -> {
Matcher matcher = NUMBER.matcher(year.asText());
if (matcher.find()) {
years.accept(Integer.parseInt(matcher.group(1)));
}
});
if (years.getCount() > 0) {
plan.put("planningPeriod", years.getMin() == years.getMax()
? Integer.toString(years.getMin())
: years.getMin() + "-" + years.getMax());
plan.put("planningYears", years.getMax() - years.getMin() + 1);
}
return;
}
String text = value.asText();
Matcher range = YEAR_RANGE.matcher(text);
if (range.find()) {
int years = Integer.parseInt(range.group(2)) - Integer.parseInt(range.group(1)) + 1;
plan.put("planningPeriod", text);
plan.put("planningYears", Math.max(1, years));
return;
}
Matcher number = NUMBER.matcher(text);
if (number.find()) {
plan.put("planningPeriod", text);
plan.put("planningYears", Math.max(1, Integer.parseInt(number.group(1))));
}
}
private boolean hasText(JsonNode value, String field) {
return value.path(field).isTextual() && !value.path(field).asText().isBlank();
}
private ApiException invalidPlan() {
return new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"PLAN_OUTPUT_INVALID",
"Agent 生成的建设规划结构不完整,请重试材料检验");
}
}

View File

@@ -0,0 +1,684 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.artifact.ArtifactService;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectFileService;
import cn.alphaline.smartfactory.project.ProjectService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Sinks;
/**
* 驱动材料检验、规划 Ask 和自动编写 Run。
*/
@Service
public class AgentRunService {
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
private final AgentExecutionService executionService;
private final AgentOutputService outputService;
private final AgentRunStore runStore;
private final AgentEventService eventService;
private final ProjectService projectService;
private final ProjectFileService fileService;
private final UserService userService;
private final ArtifactService artifactService;
private final ExecutorService executor;
private final TransactionTemplate transactions;
private final ConcurrentMap<UUID, RunControl> activeRuns = new ConcurrentHashMap<>();
/**
* 创建 Agent Run 服务。
*
* @param jdbc JDBC 客户端
* @param objectMapper JSON 映射器
* @param executionService Agent 执行服务
* @param outputService Agent 结构化输出服务
* @param runStore Run 状态存储
* @param eventService 事件服务
* @param projectService 项目服务
* @param fileService 材料与工作区服务
* @param userService 用户服务
* @param artifactService 产物服务
* @param applicationExecutor 虚拟线程执行器
* @param transactions 编程式事务模板
*/
public AgentRunService(
JdbcClient jdbc,
ObjectMapper objectMapper,
AgentExecutionService executionService,
AgentOutputService outputService,
AgentRunStore runStore,
AgentEventService eventService,
ProjectService projectService,
ProjectFileService fileService,
UserService userService,
ArtifactService artifactService,
ExecutorService applicationExecutor,
TransactionTemplate transactions) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.executionService = executionService;
this.outputService = outputService;
this.runStore = runStore;
this.eventService = eventService;
this.projectService = projectService;
this.fileService = fileService;
this.userService = userService;
this.artifactService = artifactService;
this.executor = applicationExecutor;
this.transactions = transactions;
}
/**
* 后台启动材料检验。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 新 Run
*/
@Transactional
public RunView startMaterialCheck(UUID projectId, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView run = runStore.create(projectId, "INITIAL", null);
projectService.updateStatus(projectId, "MATERIAL_CHECK");
afterCommit(run.id(), () -> executeMaterialRun(project, run, false));
return run;
}
/**
* 确认材料检验结果并后台生成建设规划。
*
* @param projectId 项目 ID
* @param response 用户对材料缺口的处理结果
* @param principal 当前用户
* @return 新规划 Run
*/
@Transactional
public RunView confirmMaterials(UUID projectId, JsonNode response, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView waiting = runStore.requireWaiting(projectId, "material_check");
if (!response.isObject() || !response.path("decisions").isArray()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口");
}
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", response);
runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id());
projectService.updateStatus(projectId, "PLANNING");
afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false));
return run;
}
/**
* 在一个事务中确认规划并启动自动编写,重复提交返回既有结果。
*
* @param projectId 项目 ID
* @param planId 规划 ID
* @param confirmedPlan 用户确认后的规划
* @param principal 当前用户
* @return 已确认规划及编写 Run
*/
@Transactional
public ConfirmPlanResult confirmPlanAndStartWriting(
UUID projectId,
UUID planId,
JsonNode confirmedPlan,
Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
ProjectService.PlanView current = projectService.currentPlan(projectId);
if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) {
RunView existing = latest(projectId);
if (existing != null && !"WAITING_INPUT".equals(existing.status())) {
return new ConfirmPlanResult(current, existing);
}
}
outputService.validateConfirmedPlan(confirmedPlan);
RunView waiting = runStore.requireWaiting(projectId, "planning");
ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, principal);
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", Map.of("planId", planId));
runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id());
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false));
return new ConfirmPlanResult(plan, run);
}
/**
* 恢复已确认但尚未完成的自动编写任务。
*
* @param projectId 项目 ID
* @return 编写 Run
*/
@Transactional
public RunView startWriting(UUID projectId) {
ProjectService.ProjectView project = projectService.require(projectId);
ProjectService.PlanView plan = projectService.currentPlan(projectId);
if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "请先确认建设规划");
}
RunView latest = latest(projectId);
if (latest != null && "RUNNING".equals(latest.status())) {
return latest;
}
if (latest != null && "WAITING_INPUT".equals(latest.status())) {
runStore.completeWaiting(latest.id());
}
RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id());
projectService.updateStatus(projectId, "WRITING");
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false));
return run;
}
/**
* 立即停止当前 Run并保留项目阶段和工作区成果。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 已中断的 Run
*/
@Transactional
public RunView stop(UUID projectId, Principal principal) {
projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView run = latest(projectId);
if (run == null || !"RUNNING".equals(run.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
}
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("id", run.id())
.update();
requireTerminalUpdate(updated);
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
onCommit(() -> {
RunControl control = activeRuns.get(run.id());
if (control != null) {
control.cancel();
}
});
return runStore.require(run.id());
}
/**
* 从已中断 Run 的原阶段继续,复用同一线程状态和工作区成果。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 新的恢复 Run
*/
@Transactional
public RunView resume(UUID projectId, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId);
if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
}
String phase = runStore.interruptedPhase(interrupted, project);
RunView run = runStore.create(projectId, "RESUME", interrupted.id());
projectService.updateStatus(projectId, phase);
switch (phase) {
case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true));
case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(projectId);
afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true));
}
case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(projectId);
if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
}
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true));
}
default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
}
return run;
}
/**
* 返回项目最近一次 Run。
*
* @param projectId 项目 ID
* @return Run未执行时为空
*/
public RunView latest(UUID projectId) {
return runStore.latest(projectId);
}
private void executeMaterialRun(ProjectService.ProjectView project, RunView run, boolean resuming) {
try {
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
"threadId", project.threadId(), "runId", run.id(), "phase", "MATERIAL_CHECK"));
List<ProjectFileService.FileView> files = fileService.list(project.id());
String fileSummary = files.isEmpty()
? "没有企业材料,当前仅有企业名称。"
: files.stream().map(file -> file.relativePath() + "" + file.extension() + "")
.collect(java.util.stream.Collectors.joining(""));
String prompt = """
现在只执行材料检验。企业:%s申报等级%s。
已上传材料:%s
请递归读取 inputs/ 下材料,先调用相应文档 Skill再调用企业画像、材料诊断与知识库 Skill输出简洁的事实与缺口摘要。
若结构化读取无法覆盖扫描页、流程图或关键版面,可按需调用 document_view不要默认渲染全部页面。
材料存在时不得虚构材料事实;知识库内容只能补充背景、政策和规划依据,不能冒充企业事实。
完成首轮目录清点后,先创建满足下述结构的 JSON 初稿,再在读取过程中持续更新,避免把必需产物留到最后。
结束前必须把检验结果写入 work/facts/material-check.json使用 UTF-8 严格 JSON不能包含 Markdown。
JSON 必须包含 summary、completeness0-100 整数、confirmedFacts字符串数组和 missingItems对象数组
每个 missingItems 对象必须包含 id、label、reason、required。即使仅有企业名称也要给出可继续规划的最小缺口清单。
暂时不要生成建设规划或 DOCX。
完成后仅输出材料覆盖、已确认事实和待确认缺口,不说明内部文件、格式、工具、命令、落盘或校验过程。
""".formatted(project.companyName(), project.applicationLevel(), fileSummary);
streamAgent(project, run, recoveryPrompt(prompt, resuming));
ObjectNode report = readMaterialCheckWithRepair(project, run);
ObjectNode ask = objectMapper.createObjectNode();
ask.put("kind", "material_check");
ask.put("interruptId", "materials-" + run.id());
ask.put("title", "确认材料检验");
ask.put("description", "确认缺口处理方式后生成建设规划");
ask.set("report", report);
finishWaiting(project.id(), run.id(), ask);
} catch (Exception exception) {
failUnlessInterrupted(run, exception);
}
}
/**
* 读取材料检验结果Agent 正常结束但未写出有效文件时,把校验错误反馈给同一线程继续修复。
*
* @param project 当前项目
* @param run 当前 Run
* @return 有效材料检验结果
* @throws IOException 修复后产物仍无法读取时抛出
*/
private ObjectNode readMaterialCheckWithRepair(ProjectService.ProjectView project, RunView run)
throws IOException {
int repairRound = 0;
while (true) {
try {
return outputService.readMaterialCheck(project.id());
} catch (ApiException | IOException exception) {
runStore.ensureRunning(run.id());
repairRound++;
log.warn("Agent 未生成有效材料检验结果继续同一线程修复runId={}round={}",
run.id(), repairRound, exception);
streamAgent(project, run, """
材料检验结构化产物校验失败:%s
请使用简体中文,优先复用当前上下文及 work/extracted 中已有结果,停止大范围补充探索。
立即修复并写入 work/facts/material-check.json使用 UTF-8 严格 JSON再调用 read_file 复核。
必须包含 summary、completeness、confirmedFacts、missingItems每个 missingItems 对象必须包含
id、label、reason、required。完成有效文件后再结束本轮。
""".formatted(exception.getMessage()));
}
}
}
/**
* 根据材料确认结果自动生成建设规划并进入规划 Ask。
*
* @param project 企业项目
* @param run 当前 Run
* @param userId 当前用户 ID
* @param materialResponse 材料确认结果
*/
private void executePlanningRun(
ProjectService.ProjectView project,
RunView run,
UUID userId,
JsonNode materialResponse,
boolean resuming) {
try {
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
"threadId", project.threadId(), "runId", run.id(), "phase", "PLANNING"));
String prompt = """
现在生成建设规划。企业:%s申报等级%s。
材料检验确认结果:%s
请调用知识库、差距分析和建设规划 Skill自主形成与企业名称、行业线索和申报等级一致的方案。
已有企业材料中的事实不得改写或补造;缺少事实时允许使用知识库形成明确标记的规划假设。
结束前必须把建议规划写入 work/plans/proposed-plan.json使用 UTF-8 严格 JSON不能包含 Markdown。
JSON 必须包含 coreDirection、collaborationDirection、factoryName、planningYears1-10 的整数、investmentRange、
applicationLevel、scenarios字符串数组、aiScenarioCount整数和 assumptions字符串数组
暂时不要编写最终 DOCX。
完成后仅输出用户确认规划所需的方向、周期、投资、场景和假设,不说明内部文件、格式、工具、命令、落盘或校验过程。
""".formatted(project.companyName(), project.applicationLevel(), materialResponse.toString());
streamAgent(project, run, recoveryPrompt(prompt, resuming));
ObjectNode plan = readProposedPlanWithRepair(project, run);
ProjectService.PlanView saved = projectService.saveDraftPlan(project.id(), plan, userId);
ObjectNode ask = objectMapper.createObjectNode();
ask.put("kind", "planning");
ask.put("interruptId", "plan-" + saved.id());
ask.put("title", "确认建设规划");
ask.put("description", "确认后 Agent 将自主完成申报书编写与评审");
ask.set("plan", plan);
ask.put("planId", saved.id().toString());
finishWaiting(project.id(), run.id(), ask);
} catch (Exception exception) {
failUnlessInterrupted(run, exception);
}
}
/**
* 读取建议规划;结构无效时把原因反馈给同一线程继续修复。
*
* @param project 当前项目
* @param run 当前 Run
* @return 有效建设规划
* @throws IOException 文件无法读取时抛出
*/
private ObjectNode readProposedPlanWithRepair(ProjectService.ProjectView project, RunView run)
throws IOException {
int repairRound = 0;
while (true) {
try {
return outputService.readProposedPlan(project.id());
} catch (ApiException | IOException exception) {
runStore.ensureRunning(run.id());
repairRound++;
log.warn("Agent 未生成有效建设规划继续同一线程修复runId={}round={}",
run.id(), repairRound, exception);
streamAgent(project, run, """
建设规划结构化产物校验失败:%s
请使用简体中文,复用当前上下文,立即修复 work/plans/proposed-plan.json 并调用 read_file 复核。
必须包含 coreDirection、collaborationDirection、factoryName、planningYears1-10 整数)、
investmentRange、applicationLevel、scenarios非空数组、aiScenarioCount 和 assumptions。
完成有效文件后再结束本轮。
""".formatted(exception.getMessage()));
}
}
}
private void executeWritingRun(
ProjectService.ProjectView project,
ProjectService.PlanView plan,
RunView run,
boolean resuming) {
try {
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
"threadId", project.threadId(), "runId", run.id(), "phase", "WRITING"));
String prompt = """
建设规划已确认。企业:%s申报等级%s。
确认规划 JSON%s
请自主完成完整申报书:调用分章编写、总结、评审和 docx Skill按需调用百炼知识库。
你拥有当前项目工作区的读、写和 shell 能力。最终文件必须保存到 artifacts/,扩展名为 .docx。
任何有企业材料可核对的内容必须据实;缺少企业事实的内容允许结合知识库形成合理方案,且在 Word 原生批注中标明待确认。
正文目标为 1 万至 2 万汉字。关键现状未知可以待确认;建设场景、技术路线、实施阶段、建议 KPI 和保障机制等未来规划必须结合材料、知识库和 Skill 充分展开,不能以待确认占位代替可合理形成的方案。
最终事实审计必须逐句检查:未知企业现状使用“需确认是否……”或“待企业提供……”等非断言句式,严禁先写肯定事实再附待确认批注;企业承诺必须写为待签署或待提供。
每个 Word 原生批注 ID 只能锚定一处commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一问题在多处引用时必须复制批注正文并分配新的唯一 ID。
即使 artifacts/ 已有旧稿,也必须为本轮重新生成并覆盖 DOCX再执行结构、一致性和事实边界校验。
请保持章节口径一致,完成评审后再交付。
最终回复只说明申报书完成情况和用户需要关注的确认事项,不汇报内部文件、工具、命令、落盘或校验过程。
""".formatted(project.companyName(), project.applicationLevel(), plan.plan().toString());
streamAgent(project, run, recoveryPrompt(prompt, resuming));
JsonNode metadata = objectMapper.valueToTree(Map.of(
"planVersion", plan.version(),
"companyName", project.companyName(),
"review", "DOCX 已通过结构与一致性校验"));
publishAndComplete(project.id(), run, metadata);
} catch (Exception exception) {
failUnlessInterrupted(run, exception);
}
}
private void streamAgent(ProjectService.ProjectView project, RunView run, String prompt) {
RunControl control = activeRuns.get(run.id());
executionService.execute(
project,
run,
"""
用户可见正文和执行说明默认使用简体中文;代码、命令、文件路径、标准原文和专有名词可保留原语言。
""" + prompt,
control == null ? reactor.core.publisher.Mono.never() : control.stopSignal.asMono(),
() -> runStore.ensureRunning(run.id()),
() -> runStore.isInterrupted(run.id()));
}
/**
* 将运行切换为等待输入。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param interrupt Ask 内容
*/
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
transactions.executeWithoutResult(status -> {
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'WAITING_INPUT', pending_interrupt = CAST(:interrupt AS jsonb),
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("interrupt", interrupt.toString())
.param("id", runId)
.update();
requireTerminalUpdate(updated);
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
});
}
/**
* 在当前事务成功提交后启动可中断的后台任务。
*
* @param runId Run ID
* @param task 后台任务
*/
private void afterCommit(UUID runId, Runnable task) {
RunControl control = new RunControl();
activeRuns.put(runId, control);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executor.submit(() -> {
try {
task.run();
} finally {
activeRuns.remove(runId, control);
}
});
}
@Override
public void afterCompletion(int status) {
if (status != TransactionSynchronization.STATUS_COMMITTED) {
activeRuns.remove(runId, control);
}
}
});
}
/**
* 在当前事务成功提交后执行短操作。
*
* @param action 提交后操作
*/
private void onCommit(Runnable action) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
}
/**
* 为用户停止后的恢复 Run 增加上下文接续约束。
*
* @param prompt 当前阶段提示词
* @param resuming 是否恢复执行
* @return 最终提示词
*/
private String recoveryPrompt(String prompt, boolean resuming) {
if (!resuming) {
return prompt;
}
return """
这是用户停止后的继续运行。恢复同一 threadId 的会话状态,并先读取 MEMORY.md、work、references、artifacts。
复用已完成成果,从中断位置继续;不要重复已经完成的工具调用,先校验可能未完整写入的中间文件。
""" + prompt;
}
/**
* 已中断的 Run 不再写入失败终态。
*
* @param run 当前 Run
* @param exception 执行异常
*/
private void failUnlessInterrupted(RunView run, Exception exception) {
if (exception instanceof AgentExecutionService.RunInterruptedException || runStore.isInterrupted(run.id())) {
log.info("Agent Run 已停止runId={}", run.id());
return;
}
fail(run, exception);
}
/**
* 在一个事务中发布产物并标记运行正常完成。
*
* @param projectId 项目 ID
* @param run 当前 Run
* @param metadata 产物元数据
*/
private void publishAndComplete(UUID projectId, RunView run, JsonNode metadata) {
transactions.executeWithoutResult(status -> {
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
projectId, run.id(), run.startedAt().toInstant(), metadata);
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL,
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("id", run.id())
.update();
requireTerminalUpdate(updated);
projectService.updateStatus(projectId, "DELIVERED");
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
});
}
/**
* 原子标记运行失败并写入终止事件,已终止的 Run 不重复写事件。
*
* @param run 当前 Run
* @param exception 执行异常
*/
private void fail(RunView run, Exception exception) {
log.error("Agent Run 执行失败runId={}", run.id(), exception);
String message = exception instanceof ApiException && exception.getMessage() != null
? exception.getMessage()
: "Agent 执行失败,请稍后重试";
try {
transactions.executeWithoutResult(status -> {
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED',
error_message = :message, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("message", message)
.param("id", run.id())
.update();
if (updated == 1) {
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
"code", "AGENT_RUN_FAILED", "message", message));
projectService.updateStatus(run.projectId(), "FAILED");
}
});
} catch (RuntimeException eventException) {
exception.addSuppressed(eventException);
log.error("Agent 失败事件持久化失败runId={}", run.id(), eventException);
}
}
/**
* 校验终态更新只命中当前运行中的 Run。
*
* @param updated 更新行数
*/
private void requireTerminalUpdate(int updated) {
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_FINISHED", "当前任务已经结束");
}
}
/**
* Agent Run 视图。
*
* @param id Run ID
* @param projectId 项目 ID
* @param triggerType 触发类型
* @param status 运行状态
* @param pendingInterrupt 待处理 Ask JSON
* @param errorMessage 失败信息
* @param startedAt 开始时间
* @param endedAt 结束时间
*/
public record RunView(
UUID id,
UUID projectId,
String triggerType,
String status,
String pendingInterrupt,
String errorMessage,
OffsetDateTime startedAt,
OffsetDateTime endedAt) {
}
/**
* 规划确认与编写启动的原子操作结果。
*
* @param plan 已确认规划
* @param run 编写 Run
*/
public record ConfirmPlanResult(ProjectService.PlanView plan, RunView run) {
}
/**
* 保存活跃任务的流取消信号。
*/
private static final class RunControl {
private final Sinks.One<Void> stopSignal = Sinks.one();
/**
* 取消 Agent 流订阅,停止继续输出和后续工具调用。
*/
private void cancel() {
stopSignal.tryEmitEmpty();
}
}
}

View File

@@ -0,0 +1,233 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
/**
* 集中读写 Agent Run 持久化状态。
*/
@Service
public class AgentRunStore {
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
/**
* 创建 Run 状态存储。
*
* @param jdbc JDBC 客户端
* @param objectMapper JSON 映射器
*/
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
/**
* 创建无并发冲突的新 Run。
*
* @param projectId 项目 ID
* @param triggerType 触发类型
* @param parentRunId 父 Run ID
* @return 新 Run
*/
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
Integer active = jdbc.sql("""
SELECT count(*) FROM app.agent_run
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
""")
.param("projectId", projectId)
.query(Integer.class)
.single();
if (active > 0) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
}
UUID id = UUID.randomUUID();
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.single();
jdbc.sql("""
INSERT INTO app.agent_run(
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id)
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId)
""")
.param("id", id)
.param("projectId", projectId)
.param("parentRunId", parentRunId)
.param("modelId", modelId)
.param("triggerType", triggerType)
.param("traceId", UUID.randomUUID().toString())
.update();
return require(id);
}
/**
* 返回项目最近 Run。
*
* @param projectId 项目 ID
* @return 最近 Run不存在时为空
*/
public AgentRunService.RunView latest(UUID projectId) {
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1")
.param("projectId", projectId)
.query(AgentRunStore::mapRun)
.optional()
.orElse(null);
}
/**
* 获取指定 Run。
*
* @param id Run ID
* @return Run
*/
public AgentRunService.RunView require(UUID id) {
return jdbc.sql(RUN_SELECT + " WHERE id = :id")
.param("id", id)
.query(AgentRunStore::mapRun)
.single();
}
/**
* 完成等待输入的 Run。
*
* @param runId Run ID
*/
public void completeWaiting(UUID runId) {
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'WAITING_INPUT'
""")
.param("id", runId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
}
}
/**
* 获取当前等待中的指定 Ask。
*
* @param projectId 项目 ID
* @param kind Ask 类型
* @return 等待中的 Run
*/
public AgentRunService.RunView requireWaiting(UUID projectId, String kind) {
AgentRunService.RunView run = latest(projectId);
if (run == null || !"WAITING_INPUT".equals(run.status()) || run.pendingInterrupt() == null) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_NOT_WAITING", "当前没有等待确认的内容");
}
try {
if (!kind.equals(objectMapper.readTree(run.pendingInterrupt()).path("kind").asText())) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致");
}
return run;
} catch (JsonProcessingException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取");
}
}
/**
* 确保 Run 仍处于运行状态。
*
* @param runId Run ID
*/
public void ensureRunning(UUID runId) {
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id")
.param("id", runId)
.query(String.class)
.single();
if (!"RUNNING".equals(status)) {
throw new AgentExecutionService.RunInterruptedException();
}
}
/**
* 判断 Run 是否已由用户停止。
*
* @param runId Run ID
* @return 是否已停止
*/
public boolean isInterrupted(UUID runId) {
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id")
.param("id", runId)
.query(Boolean.class)
.optional()
.orElse(false);
}
/**
* 从持久化事件读取中断前阶段。
*
* @param run 已中断 Run
* @param project 当前项目
* @return 业务阶段
*/
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
return jdbc.sql("""
SELECT payload ->> 'phase' FROM app.agent_event
WHERE run_id = :runId AND event_type = 'RUN_STARTED'
ORDER BY id DESC LIMIT 1
""")
.param("runId", run.id())
.query(String.class)
.optional()
.orElse(project.status());
}
/**
* 读取规划恢复所需的最近材料确认结果。
*
* @param projectId 项目 ID
* @return 材料确认 JSON
*/
public JsonNode latestMaterialResponse(UUID projectId) {
return jdbc.sql("""
SELECT payload::text FROM app.agent_event
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED'
AND jsonb_typeof(payload -> 'decisions') = 'array'
ORDER BY id DESC LIMIT 1
""")
.param("projectId", projectId)
.query(String.class)
.optional()
.map(value -> {
try {
return objectMapper.readTree(value);
} catch (JsonProcessingException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MATERIAL_RESPONSE_INVALID",
"材料确认记录无法读取");
}
})
.orElseGet(objectMapper::createObjectNode);
}
private static AgentRunService.RunView mapRun(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new AgentRunService.RunView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("trigger_type"),
rs.getString("status"),
rs.getString("pending_interrupt"),
rs.getString("error_message"),
rs.getObject("started_at", OffsetDateTime.class),
rs.getObject("ended_at", OffsetDateTime.class));
}
private static final String RUN_SELECT = """
SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at
FROM app.agent_run
""";
}

View File

@@ -0,0 +1,169 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.harness.agent.HarnessAgent;
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse;
import io.agentscope.harness.agent.filesystem.model.WriteResult;
import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片。
*/
public final class DocumentViewTool {
private static final int TOOL_TIMEOUT_SECONDS = 180;
private final ObjectMapper objectMapper;
private volatile HarnessAgent harness;
/**
* 创建文档视觉工具。
*
* @param objectMapper JSON 映射器
*/
public DocumentViewTool(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 绑定拥有沙箱工作区的 Harness Agent。
*
* @param harness 当前工具所属的 Harness Agent
*/
void bind(HarnessAgent harness) {
this.harness = harness;
}
/**
* 渲染一个或多个文档页面、幻灯片、工作表范围或图片,并把图片返回给多模态模型。
*
* @param views 查看请求;建议每次不超过 5 个,允许按任务需要分批调用
* @param runtimeContext 当前运行上下文
* @return 包含结构化结果和成功图片的工具观察
*/
@Tool(
name = "document_view",
description = "按需查看 PDF/DOCX 页、PPTX 幻灯片、XLS/XLSX 工作表范围或图片。"
+ "先使用文档 Skill 做结构化读取,只有内容缺失、扫描件或视觉布局重要时再调用。"
+ "views 支持多个请求,建议每次最多 5 个;单个失败不影响其他结果。",
readOnly = true)
public ToolResultBlock view(
@ToolParam(
name = "views",
description = "查看请求数组。path 为工作区相对路径PDF/DOCX/PPTX 可给 page"
+ "XLS/XLSX 可给 sheet 和 range可选 dpi 72-220、format 为 png/jpeg。")
List<ViewRequest> views,
RuntimeContext runtimeContext) {
if (views == null || views.isEmpty()) {
return ToolResultBlock.text("document_view 执行失败views 至少包含一个查看请求");
}
HarnessAgent activeHarness = harness;
if (activeHarness == null) {
return ToolResultBlock.text("document_view 执行失败:当前 Agent 没有 Harness 工作区");
}
AbstractFilesystem filesystem = activeHarness.getWorkspaceManager().getFilesystem();
if (!(filesystem instanceof SandboxBackedFilesystem sandbox)) {
return ToolResultBlock.text("document_view 执行失败:当前工作区不支持文档渲染");
}
String directory = "work/tmp/document-view/" + UUID.randomUUID();
String requestPath = directory + "/request.json";
String resultPath = directory + "/result.json";
try {
WriteResult write = filesystem.write(
runtimeContext,
requestPath,
objectMapper.writeValueAsString(Map.of("views", views)));
if (!write.isSuccess()) {
return ToolResultBlock.text("document_view 执行失败:" + write.error());
}
ExecuteResponse execution = sandbox.execute(
runtimeContext,
"python /opt/agent-runtime/document_view.py " + requestPath + " " + resultPath,
TOOL_TIMEOUT_SECONDS);
FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst();
if (!downloaded.isSuccess()) {
return ToolResultBlock.text("document_view 执行失败:"
+ safeError(execution.output(), downloaded.error()));
}
JsonNode result = objectMapper.readTree(new String(downloaded.content(), StandardCharsets.UTF_8));
List<ContentBlock> output = new ArrayList<>();
output.add(TextBlock.builder()
.text("document_view_result=" + objectMapper.writeValueAsString(result))
.build());
List<Map<String, Object>> metadata = new ArrayList<>();
for (JsonNode image : result.path("images")) {
String path = image.path("path").asText();
FileDownloadResponse imageFile = filesystem.downloadFiles(runtimeContext, List.of(path)).getFirst();
if (!imageFile.isSuccess()) {
continue;
}
output.add(ImageBlock.builder()
.source(Base64Source.builder()
.mediaType(image.path("mediaType").asText("image/png"))
.data(Base64.getEncoder().encodeToString(imageFile.content()))
.build())
.build());
metadata.add(objectMapper.convertValue(image, new TypeReference<LinkedHashMap<String, Object>>() { }));
}
return ToolResultBlock.of(output, Map.of("images", metadata));
} catch (Exception exception) {
return ToolResultBlock.text("document_view 执行失败:" + safeError(exception.getMessage(), null));
}
}
/**
* 选择并限制返回给 Agent 的诊断信息。
*
* @param primary 首选错误信息
* @param fallback 备用错误信息
* @return 有界错误文本
*/
private String safeError(String primary, String fallback) {
String value = primary == null || primary.isBlank() ? fallback : primary;
if (value == null || value.isBlank()) {
return "未返回可诊断信息,请缩小查看范围后重试";
}
value = value.strip();
return value.length() > 600 ? value.substring(value.length() - 600) : value;
}
/**
* 单个文档查看参数。
*
* @param path 工作区相对路径
* @param page 一基页码或幻灯片编号
* @param sheet 工作表名称
* @param range Excel A1 范围
* @param dpi 渲染 DPI
* @param format png 或 jpeg
*/
public record ViewRequest(
@ToolParam(name = "path", description = "工作区相对路径") String path,
@ToolParam(name = "page", required = false, description = "一基页码或幻灯片编号") Integer page,
@ToolParam(name = "sheet", required = false, description = "Excel 工作表名称") String sheet,
@ToolParam(name = "range", required = false, description = "Excel A1 范围,例如 A1:H30") String range,
@ToolParam(name = "dpi", required = false, description = "渲染 DPI建议 120-180") Integer dpi,
@ToolParam(name = "format", required = false, description = "png 或 jpeg") String format) {
}
}

View File

@@ -0,0 +1,191 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.model.ReadResult;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 提供带明确续读位置的文件读取工具,避免沙箱输出上限造成静默截断。
*/
final class PagedReadFileTool {
static final int DEFAULT_PAGE_LINES = 400;
private static final int SAFE_PAGE_BYTES = 300 * 1024;
private final AbstractFilesystem filesystem;
private final WorkspacePathNormalizer pathNormalizer;
private final ObjectMapper objectMapper;
/**
* 创建分页读取工具。
*
* @param filesystem AgentScope 文件系统
* @param pathNormalizer 工作区路径归一化器
* @param objectMapper JSON 映射器
*/
PagedReadFileTool(
AbstractFilesystem filesystem,
WorkspacePathNormalizer pathNormalizer,
ObjectMapper objectMapper) {
this.filesystem = filesystem;
this.pathNormalizer = pathNormalizer;
this.objectMapper = objectMapper;
}
/**
* 按行读取文件;内容未读完时返回下一页 offset。
*
* @param runtimeContext Agent 运行上下文
* @param path 文件路径
* @param offset 起始行,基于零
* @param limit 本页最多行数
* @return 文件内容及必要的续读提示
*/
@Tool(
name = "read_file",
readOnly = true,
description =
"Read UTF-8 file content by lines. When content remains, the result explicitly"
+ " provides nextOffset for the next call.")
public String readFile(
RuntimeContext runtimeContext,
@ToolParam(name = "path", description = "File path to read") String path,
@ToolParam(
name = "offset",
description = "Start line (0-indexed). Default: 0",
required = false)
Integer offset,
@ToolParam(
name = "limit",
description = "Max lines to return. Default: 400",
required = false)
Integer limit) {
int start = offset == null ? 0 : offset;
int pageLines = limit == null || limit <= 0 ? DEFAULT_PAGE_LINES : limit;
if (start < 0) {
return "Error: offset 不能小于 0";
}
String normalizedPath = pathNormalizer.normalize(path);
if (filesystem instanceof AbstractSandboxFilesystem sandbox) {
return readFromSandbox(sandbox, runtimeContext, normalizedPath, start, pageLines);
}
return readFromFilesystem(runtimeContext, normalizedPath, start, pageLines);
}
private String readFromSandbox(
AbstractSandboxFilesystem sandbox,
RuntimeContext runtimeContext,
String path,
int offset,
int limit) {
String encodedPath = Base64.getEncoder().encodeToString(path.getBytes(StandardCharsets.UTF_8));
String command = """
python3 - <<'PY'
import base64, json
path = base64.b64decode('%s').decode('utf-8')
offset = %d
limit = %d
cap = %d
try:
with open(path, 'rb') as source:
text = source.read().decode('utf-8')
lines = text.splitlines()
start = min(offset, len(lines))
selected = []
size = 0
line_too_long = False
for line in lines[start:start + limit]:
data = (line + '\\n').encode('utf-8')
if size + len(data) > cap:
line_too_long = not selected
break
selected.append(line)
size += len(data)
next_offset = start + len(selected)
meta = {
'ok': True,
'truncated': next_offset < len(lines),
'nextOffset': next_offset,
'returnedLines': len(selected),
'lineTooLong': line_too_long
}
print(json.dumps(meta, ensure_ascii=False, separators=(',', ':')))
print(base64.b64encode('\\n'.join(selected).encode('utf-8')).decode('ascii'))
except FileNotFoundError:
print(json.dumps({'ok': False, 'error': 'file_not_found'}, separators=(',', ':')))
except UnicodeDecodeError:
print(json.dumps({'ok': False, 'error': 'not_utf8_text'}, separators=(',', ':')))
except Exception as error:
print(json.dumps({'ok': False, 'error': str(error)}, ensure_ascii=False, separators=(',', ':')))
PY
""".formatted(encodedPath, offset, limit, SAFE_PAGE_BYTES);
ExecuteResponse response = sandbox.execute(runtimeContext, command, null);
String output = response.output() == null ? "" : response.output();
int split = output.indexOf('\n');
String header = split < 0 ? output.strip() : output.substring(0, split).strip();
try {
JsonNode meta = objectMapper.readTree(header);
if (!meta.path("ok").asBoolean()) {
return switch (meta.path("error").asText()) {
case "file_not_found" -> "Error: 文件不存在:" + path;
case "not_utf8_text" -> "Error: 文件不是 UTF-8 文本,请调用相应文档 Skill 或 document_view";
default -> "Error: 读取文件失败:" + meta.path("error").asText("未知错误");
};
}
int nextOffset = meta.path("nextOffset").asInt(offset);
if (response.truncated()) {
return incompleteNotice(path, nextOffset, limit);
}
String encoded = split < 0 ? "" : output.substring(split + 1).strip();
String content = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
if (meta.path("lineTooLong").asBoolean()) {
return "Error: 当前行超过安全读取范围,请使用 execute_shell_command 分段读取该行;内容未被静默截断";
}
return meta.path("truncated").asBoolean()
? content + "\n\n" + incompleteNotice(path, nextOffset, limit)
: content;
} catch (Exception exception) {
return "Error: 无法解析文件读取结果,内容可能未读完,请缩小 limit 后重试";
}
}
private String readFromFilesystem(
RuntimeContext runtimeContext, String path, int offset, int limit) {
ReadResult result = filesystem.read(runtimeContext, path, offset, limit + 1);
if (!result.isSuccess()) {
return "Error: " + result.error();
}
if (result.fileData() == null) {
return "";
}
if (!"utf-8".equalsIgnoreCase(result.fileData().encoding())) {
return result.fileData().content();
}
String[] lines = result.fileData().content().split("\\R", -1);
if (lines.length <= limit) {
return result.fileData().content();
}
return String.join("\n", java.util.Arrays.copyOf(lines, limit))
+ "\n\n"
+ incompleteNotice(path, offset + limit, limit);
}
private String incompleteNotice(String path, int nextOffset, int limit) {
return "[系统提示:内容未读完。请继续调用 read_file(path=\""
+ path
+ "\", offset="
+ nextOffset
+ ", limit="
+ limit
+ ")。]";
}
}

View File

@@ -0,0 +1,44 @@
package cn.alphaline.smartfactory.agent;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
*/
@Component
@Order(0)
public class RunRecoveryService implements ApplicationRunner {
private final JdbcClient jdbc;
/**
* 创建恢复服务。
*
* @param jdbc JDBC 客户端
*/
public RunRecoveryService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
/**
* 将仍为 RUNNING 的旧 Run 标记为已中断。
*
* @param args 启动参数
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
jdbc.sql("""
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE status = 'RUNNING'
""").update();
}
}

View File

@@ -0,0 +1,59 @@
package cn.alphaline.smartfactory.artifact;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 提供项目产物列表与下载接口。
*/
@RestController
@RequestMapping("/api")
public class ArtifactController {
private final ArtifactService artifactService;
/**
* 创建产物控制器。
*
* @param artifactService 产物服务
*/
public ArtifactController(ArtifactService artifactService) {
this.artifactService = artifactService;
}
/**
* 列出项目产物。
*
* @param projectId 项目 ID
* @return 产物列表
*/
@GetMapping("/projects/{projectId}/artifacts")
public List<ArtifactService.ArtifactView> list(@PathVariable UUID projectId) {
return artifactService.list(projectId);
}
/**
* 下载产物。
*
* @param artifactId 产物 ID
* @return 文件响应
*/
@GetMapping("/artifacts/{artifactId}/download")
public ResponseEntity<org.springframework.core.io.Resource> download(@PathVariable UUID artifactId) {
ArtifactService.Download download = artifactService.download(artifactId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(download.mimeType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
.body(download.resource());
}
}

View File

@@ -0,0 +1,307 @@
package cn.alphaline.smartfactory.artifact;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
/**
* 校验、登记和下载 Agent 最终产物。
*/
@Service
public class ArtifactService {
private final JdbcClient jdbc;
private final ProjectFileService fileService;
private final DocxValidator docxValidator;
/**
* 创建产物服务。
*
* @param jdbc JDBC 客户端
* @param fileService 项目文件服务
* @param docxValidator DOCX 校验器
*/
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
this.jdbc = jdbc;
this.fileService = fileService;
this.docxValidator = docxValidator;
}
/**
* 校验本轮沙箱候选 DOCX原子复制到正式目录并登记产物。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param runStartedAt Run 开始时间
* @param metadata 业务元数据
* @return 已发布产物
*/
public ArtifactView publishCandidate(
UUID projectId,
UUID runId,
Instant runStartedAt,
JsonNode metadata) {
Path candidates = fileService.safeProjectPath(projectId, "work/candidates");
Path candidate = newestDocx(candidates, runStartedAt);
DocxValidator.ValidationResult validation = docxValidator.validate(candidate);
Path target = fileService.safeProjectPath(projectId, "artifacts/" + candidate.getFileName());
Path temporary = target.resolveSibling(target.getFileName() + ".publishing");
try {
Files.copy(candidate, temporary, StandardCopyOption.REPLACE_EXISTING);
docxValidator.validate(temporary);
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException exception) {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupException) {
exception.addSuppressed(cleanupException);
}
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败");
}
com.fasterxml.jackson.databind.node.ObjectNode enriched = metadata.isObject()
? ((com.fasterxml.jackson.databind.node.ObjectNode) metadata).deepCopy()
: com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode();
enriched.put("docxValidated", true);
enriched.put("docxEntries", validation.entryCount());
enriched.put("commentCount", validation.commentCount());
return publish(
projectId,
runId,
"DOCX",
target.getFileName().toString(),
"artifacts/" + target.getFileName(),
enriched);
}
/**
* 发布工作区中的 DOCX 产物。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param kind 产物类型
* @param name 文件名
* @param relativePath 项目相对路径
* @param metadata 业务摘要
* @return 产物元数据
*/
public ArtifactView publish(
UUID projectId,
UUID runId,
String kind,
String name,
String relativePath,
JsonNode metadata) {
Path path = fileService.safeProjectPath(projectId, relativePath);
if (!relativePath.startsWith("artifacts/") || !Files.isRegularFile(path)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_INVALID", "产物文件不存在或不在发布目录");
}
try {
long size = Files.size(path);
if (size <= 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
}
String hash = sha256(path);
UUID id = jdbc.sql("""
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type,
size_bytes, sha256, metadata_json)
VALUES (:id, :projectId, :runId, :kind, :name, :path,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
:size, :sha256, CAST(:metadata AS jsonb))
ON CONFLICT (project_id, relative_path) DO UPDATE SET
run_id = EXCLUDED.run_id,
kind = EXCLUDED.kind,
name = EXCLUDED.name,
mime_type = EXCLUDED.mime_type,
size_bytes = EXCLUDED.size_bytes,
sha256 = EXCLUDED.sha256,
metadata_json = EXCLUDED.metadata_json,
published_at = CURRENT_TIMESTAMP
RETURNING id
""")
.param("id", UUID.randomUUID())
.param("projectId", projectId)
.param("runId", runId)
.param("kind", kind)
.param("name", name)
.param("path", relativePath)
.param("size", size)
.param("sha256", hash)
.param("metadata", metadata.toString())
.query(UUID.class)
.single();
return require(id);
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
}
}
private Path newestDocx(Path directory, Instant runStartedAt) {
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
return paths
.filter(path -> path.getFileName().toString().toLowerCase(java.util.Locale.ROOT).endsWith(".docx"))
.filter(path -> path.toFile().lastModified() >= runStartedAt.toEpochMilli())
.max(java.util.Comparator.comparingLong(path -> path.toFile().lastModified()))
.orElseThrow(() -> new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"DOCX_NOT_GENERATED",
"Agent 未生成本轮 DOCX"));
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_SCAN_FAILED", "申报书候选目录无法读取");
}
}
/**
* 列出项目产物。
*
* @param projectId 项目 ID
* @return 按发布时间倒序的产物
*/
public List<ArtifactView> list(UUID projectId) {
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
.param("projectId", projectId)
.query(ArtifactService::mapArtifact)
.list();
}
/**
* 获取产物下载资源。
*
* @param artifactId 产物 ID
* @return 下载信息
*/
public Download download(UUID artifactId) {
StoredArtifact artifact = jdbc.sql("""
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
FROM app.artifact WHERE id = :id
""")
.param("id", artifactId)
.query((rs, rowNum) -> new StoredArtifact(
rs.getObject("project_id", UUID.class),
rs.getString("name"),
rs.getString("relative_path"),
rs.getString("mime_type"),
rs.getLong("size_bytes"),
rs.getString("sha256")))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
try {
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
Resource resource = new UrlResource(path.toUri());
if (!resource.exists()) {
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_FILE_NOT_FOUND", "产物文件不存在");
}
if (Files.size(path) != artifact.sizeBytes() || !sha256(path).equals(artifact.sha256())) {
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
}
return new Download(artifact.name(), artifact.mimeType(), resource);
} catch (java.net.MalformedURLException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PATH_INVALID", "产物路径无效");
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
}
}
/**
* 计算文件 SHA-256。
*
* @param path 文件路径
* @return 小写十六进制哈希
* @throws IOException 文件读取失败时抛出
* @throws NoSuchAlgorithmException 运行环境不支持 SHA-256 时抛出
*/
private String sha256(Path path) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
for (int read; (read = input.read(buffer)) >= 0;) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
private ArtifactView require(UUID id) {
return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id")
.param("id", id)
.query(ArtifactService::mapArtifact)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
}
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ArtifactView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getObject("run_id", UUID.class),
rs.getString("kind"),
rs.getString("name"),
rs.getLong("size_bytes"),
rs.getString("metadata_json"),
rs.getObject("published_at", OffsetDateTime.class));
}
private static final String ARTIFACT_SELECT = """
SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
FROM app.artifact
""";
/**
* 产物元数据。
*
* @param id 产物 ID
* @param projectId 项目 ID
* @param runId 生成 Run
* @param kind 类型
* @param name 文件名
* @param sizeBytes 文件大小
* @param metadataJson 业务摘要 JSON
* @param publishedAt 发布时间
*/
public record ArtifactView(
UUID id,
UUID projectId,
UUID runId,
String kind,
String name,
long sizeBytes,
String metadataJson,
OffsetDateTime publishedAt) {
}
/**
* 下载结果。
*
* @param name 下载文件名
* @param mimeType MIME 类型
* @param resource 文件资源
*/
public record Download(String name, String mimeType, Resource resource) {
}
private record StoredArtifact(
UUID projectId,
String name,
String relativePath,
String mimeType,
long sizeBytes,
String sha256) {
}
}

View File

@@ -0,0 +1,194 @@
package cn.alphaline.smartfactory.artifact;
import cn.alphaline.smartfactory.common.ApiException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* 使用 JDK 标准库校验 DOCX 容器、核心 OOXML 和原生批注引用。
*/
@Component
public class DocxValidator {
private static final Set<String> REQUIRED_ENTRIES = Set.of(
"[Content_Types].xml", "_rels/.rels", "word/document.xml");
private static final int MAX_ENTRIES = 10_000;
private static final long MAX_UNCOMPRESSED_BYTES = 512L * 1024 * 1024;
/**
* 校验 DOCX 文件可以被 Word 作为完整 OOXML 文档读取。
*
* @param path DOCX 文件
* @return 校验摘要
* @throws ApiException 文件损坏、结构缺失或批注引用异常时抛出
*/
public ValidationResult validate(Path path) {
if (!Files.isRegularFile(path)) {
throw invalid("DOCX 文件不存在");
}
try (ZipFile zip = new ZipFile(path.toFile())) {
Set<String> names = new HashSet<>();
long uncompressed = 0;
var entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (name.startsWith("/") || name.contains("../") || !names.add(name)) {
throw invalid("DOCX 包含非法或重复路径");
}
if (names.size() > MAX_ENTRIES) {
throw invalid("DOCX 文件数量超过限制");
}
if (entry.getSize() > 0) {
uncompressed = Math.addExact(uncompressed, entry.getSize());
if (uncompressed > MAX_UNCOMPRESSED_BYTES) {
throw invalid("DOCX 解压后大小超过限制");
}
}
}
if (!names.containsAll(REQUIRED_ENTRIES)) {
throw invalid("DOCX 缺少必要的 Word 文档结构");
}
Document document = parse(zip, "word/document.xml");
if (!"document".equals(document.getDocumentElement().getLocalName())
|| document.getElementsByTagNameNS("*", "body").getLength() != 1
|| document.getElementsByTagNameNS("*", "t").getLength() == 0) {
throw invalid("DOCX 正文结构为空或无效");
}
int comments = validateComments(zip, names, document);
return new ValidationResult(Files.size(path), names.size(), comments);
} catch (ApiException exception) {
throw exception;
} catch (ArithmeticException | IOException exception) {
throw invalid("DOCX 容器损坏或无法读取");
}
}
private int validateComments(ZipFile zip, Set<String> names, Document document) throws IOException {
Map<String, Integer> references = idCounts(document, "commentReference");
Map<String, Integer> starts = idCounts(document, "commentRangeStart");
Map<String, Integer> ends = idCounts(document, "commentRangeEnd");
Set<String> referenced = new HashSet<>(references.keySet());
referenced.addAll(starts.keySet());
referenced.addAll(ends.keySet());
if (!names.contains("word/comments.xml")) {
if (!referenced.isEmpty()) {
throw invalid("DOCX 正文引用了不存在的批注");
}
return 0;
}
Document comments = parse(zip, "word/comments.xml");
Set<String> declared = new HashSet<>();
NodeList nodes = comments.getElementsByTagNameNS("*", "comment");
for (int index = 0; index < nodes.getLength(); index++) {
String id = attributeByLocalName((Element) nodes.item(index), "id");
if (id.isBlank() || !declared.add(id)) {
throw invalid("DOCX 包含重复或无编号批注");
}
}
if (declared.isEmpty() || !declared.equals(referenced)) {
throw invalid("DOCX 批注与正文锚点不一致");
}
for (String id : declared) {
if (references.getOrDefault(id, 0) != 1
|| starts.getOrDefault(id, 0) != 1
|| ends.getOrDefault(id, 0) != 1) {
throw invalid("DOCX 批注锚点重复或不完整");
}
}
if (!names.contains("word/_rels/document.xml.rels")) {
throw invalid("DOCX 批注缺少关系定义");
}
Document relationships = parse(zip, "word/_rels/document.xml.rels");
boolean linked = false;
NodeList relations = relationships.getElementsByTagNameNS("*", "Relationship");
for (int index = 0; index < relations.getLength(); index++) {
Element relation = (Element) relations.item(index);
if ("comments.xml".equals(relation.getAttribute("Target"))) {
linked = true;
break;
}
}
if (!linked) {
throw invalid("DOCX 批注关系未连接到正文");
}
return declared.size();
}
private Map<String, Integer> idCounts(Document document, String localName) {
Map<String, Integer> values = new HashMap<>();
NodeList nodes = document.getElementsByTagNameNS("*", localName);
for (int index = 0; index < nodes.getLength(); index++) {
String id = attributeByLocalName((Element) nodes.item(index), "id");
if (!id.isBlank()) {
values.merge(id, 1, Integer::sum);
}
}
return values;
}
private String attributeByLocalName(Element element, String localName) {
for (int index = 0; index < element.getAttributes().getLength(); index++) {
Node attribute = element.getAttributes().item(index);
if (localName.equals(attribute.getLocalName()) || localName.equals(attribute.getNodeName())) {
return attribute.getNodeValue();
}
}
return "";
}
private Document parse(ZipFile zip, String name) throws IOException {
ZipEntry entry = zip.getEntry(name);
if (entry == null) {
throw invalid("DOCX 缺少必要 XML" + name);
}
try (InputStream input = zip.getInputStream(entry)) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return factory.newDocumentBuilder().parse(input);
} catch (ApiException exception) {
throw exception;
} catch (Exception exception) {
throw invalid("DOCX XML 无法解析:" + name);
}
}
private ApiException invalid(String message) {
return new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "DOCX_INVALID", message);
}
/**
* DOCX 校验摘要。
*
* @param sizeBytes 文件大小
* @param entryCount ZIP 条目数
* @param commentCount 原生批注数
*/
public record ValidationResult(long sizeBytes, int entryCount, int commentCount) {
}
}

View File

@@ -0,0 +1,104 @@
package cn.alphaline.smartfactory.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.security.Principal;
import java.util.Map;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 管理登录状态和 CSRF Token。
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final HttpSessionSecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository();
/**
* 创建认证控制器。
*
* @param authenticationManager 认证管理器
*/
public AuthController(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
/**
* 返回并初始化 CSRF Token。
*
* @param token 当前请求 Token
* @return Token 数据
*/
@GetMapping("/csrf")
public Map<String, String> csrf(CsrfToken token) {
return Map.of("token", token.getToken(), "headerName", token.getHeaderName());
}
/**
* 使用用户名和密码创建 Session。
*
* @param request 登录请求
* @param servletRequest HTTP 请求
* @param servletResponse HTTP 响应
* @return 当前用户摘要
*/
@PostMapping("/login")
public MeResponse login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest servletRequest,
HttpServletResponse servletResponse) {
Authentication authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
contextRepository.saveContext(context, servletRequest, servletResponse);
return new MeResponse(authentication.getName(), "管理员");
}
/**
* 返回当前登录账户。
*
* @param principal 当前身份
* @return 当前用户摘要
*/
@GetMapping("/me")
public MeResponse me(Principal principal) {
return new MeResponse(principal.getName(), "管理员");
}
/**
* 登录请求。
*
* @param username 登录名
* @param password 密码
*/
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
}
/**
* 当前用户摘要。
*
* @param username 登录名
* @param displayName 显示名称
*/
public record MeResponse(String username, String displayName) {
}
}

View File

@@ -0,0 +1,97 @@
package cn.alphaline.smartfactory.auth;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import java.util.UUID;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.core.annotation.Order;
/**
* 管理单管理员账户和当前用户标识。
*/
@Service
@Order(1)
public class UserService implements UserDetailsService, ApplicationRunner {
private final JdbcClient jdbc;
private final PasswordEncoder passwordEncoder;
private final AppProperties properties;
/**
* 创建用户服务。
*
* @param jdbc JDBC 客户端
* @param passwordEncoder 密码编码器
* @param properties 应用配置
*/
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) {
this.jdbc = jdbc;
this.passwordEncoder = passwordEncoder;
this.properties = properties;
}
/**
* 初始化本地管理员账户。
*
* @param args 启动参数
*/
@Override
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single();
if (count == 0) {
jdbc.sql("""
INSERT INTO app.app_user(id, username, password_hash, display_name)
VALUES (:id, :username, :password, :displayName)
""")
.param("id", UUID.randomUUID())
.param("username", properties.adminUsername())
.param("password", passwordEncoder.encode(properties.adminPassword()))
.param("displayName", "管理员")
.update();
}
}
/**
* 加载 Spring Security 用户。
*
* @param username 登录名
* @return 用户详情
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username")
.param("username", username)
.query((rs, rowNum) -> User.withUsername(rs.getString("username"))
.password(rs.getString("password_hash"))
.roles("ADMIN")
.disabled(!rs.getBoolean("enabled"))
.build())
.optional()
.orElseThrow(() -> new UsernameNotFoundException("账户不存在"));
}
/**
* 获取指定登录名的用户 ID。
*
* @param username 登录名
* @return 用户 UUID
* @throws ApiException 用户不存在时抛出
*/
public UUID requireUserId(String username) {
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username")
.param("username", username)
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"));
}
}

View File

@@ -0,0 +1,15 @@
package cn.alphaline.smartfactory.common;
import java.time.Instant;
/**
* 统一接口错误响应。
*
* @param code 稳定错误码
* @param message 可理解错误信息
* @param traceId 日志关联标识
* @param timestamp 发生时间
*/
public record ApiError(String code, String message, String traceId, Instant timestamp) {
}

View File

@@ -0,0 +1,44 @@
package cn.alphaline.smartfactory.common;
import org.springframework.http.HttpStatus;
/**
* 可安全返回给调用方的业务异常。
*/
public class ApiException extends RuntimeException {
private final HttpStatus status;
private final String code;
/**
* 创建业务异常。
*
* @param status HTTP 状态
* @param code 稳定错误码
* @param message 可操作错误说明
*/
public ApiException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
/**
* 返回 HTTP 状态。
*
* @return HTTP 状态
*/
public HttpStatus status() {
return status;
}
/**
* 返回稳定错误码。
*
* @return 错误码
*/
public String code() {
return code;
}
}

View File

@@ -0,0 +1,135 @@
package cn.alphaline.smartfactory.common;
import jakarta.validation.ConstraintViolationException;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
/**
* 将业务错误和未预期异常转换为明确的 HTTP 错误响应。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* 处理已知业务异常。
*
* @param exception 业务异常
* @return 对应状态码的错误响应
*/
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiError> handleApiException(ApiException exception) {
return ResponseEntity.status(exception.status())
.body(error(exception.code(), exception.getMessage()));
}
/**
* 处理输入校验失败。
*
* @param exception 参数校验异常
* @return 400 错误响应
*/
@ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
public ResponseEntity<ApiError> handleValidation(Exception exception) {
String message = exception instanceof MethodArgumentNotValidException invalid
? invalid.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getField() + "" + error.getDefaultMessage())
.orElse("请求参数无效")
: exception.getMessage();
return ResponseEntity.badRequest().body(error("VALIDATION_FAILED", message));
}
/**
* 将错误凭据统一映射为 401。
*
* @param exception 认证异常
* @return 401 错误响应
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiError> handleAuthentication(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(error("AUTHENTICATION_FAILED", "用户名或密码错误"));
}
/**
* 将权限或 CSRF 拒绝统一映射为 403。
*
* @param exception 权限异常
* @return 403 错误响应
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException exception) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(error("ACCESS_DENIED", "当前请求无权执行"));
}
/**
* 处理无法解析的请求体。
*
* @param exception JSON 读取异常
* @return 400 错误响应
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleUnreadable(HttpMessageNotReadableException exception) {
return ResponseEntity.badRequest().body(error("REQUEST_BODY_INVALID", "请求内容格式无效"));
}
/**
* 处理数据库唯一性等并发冲突。
*
* @param exception 数据约束异常
* @return 409 错误响应
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiError> handleConflict(DataIntegrityViolationException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(error("DATA_CONFLICT", "数据已发生变化,请刷新后重试"));
}
/**
* 收敛流式响应中客户端主动断开产生的预期异常。
*
* @param exception 已提交响应无法继续写入的异常
*/
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleClientDisconnect(AsyncRequestNotUsableException exception) {
log.debug("客户端已断开流式响应:{}", exception.getMessage());
}
/**
* 处理未预期异常并保留完整堆栈。
*
* @param exception 未预期异常
* @return 500 错误响应
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(Exception exception) {
String traceId = traceId();
log.error("未预期异常traceId={}", traceId, exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("INTERNAL_ERROR", "服务处理失败,请稍后重试", traceId, java.time.Instant.now()));
}
private ApiError error(String code, String message) {
return new ApiError(code, message, traceId(), java.time.Instant.now());
}
private String traceId() {
String value = MDC.get("traceId");
return value == null || value.isBlank() ? UUID.randomUUID().toString() : value;
}
}

View File

@@ -0,0 +1,45 @@
package cn.alphaline.smartfactory.common;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* 为每个 HTTP 请求建立可回传、可检索的追踪编号。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
/**
* 在请求处理期间写入 MDC并在响应中返回追踪编号。
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param filterChain 后续过滤器链
* @throws ServletException Servlet 处理失败
* @throws IOException 网络读写失败
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}

View File

@@ -0,0 +1,37 @@
package cn.alphaline.smartfactory.config;
import java.nio.file.Path;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 应用自身的运行配置。
*
* @param dataRoot 项目材料、工作区和产物根目录
* @param deepseekKeyFile DeepSeek Key 文件
* @param dashscopeKeyFile 百炼 Key 文件
* @param masterKey 模型密钥加密主密钥
* @param adminUsername 本地管理员用户名
* @param adminPassword 本地管理员初始密码
* @param modelBaseUrl 默认模型端点
* @param modelId 默认模型标识
* @param modelContextWindow 默认模型上下文窗口
* @param sandboxImage Agent Docker 运行镜像
* @param sandboxNetwork Agent Docker 网络
* @param runTimeout 单次 Agent 运行超时
*/
@ConfigurationProperties(prefix = "app")
public record AppProperties(
Path dataRoot,
Path deepseekKeyFile,
Path dashscopeKeyFile,
String masterKey,
String adminUsername,
String adminPassword,
String modelBaseUrl,
String modelId,
int modelContextWindow,
String sandboxImage,
String sandboxNetwork,
Duration runTimeout) {
}

View File

@@ -0,0 +1,36 @@
package cn.alphaline.smartfactory.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
/**
* 进程内基础设施配置。
*/
@Configuration
public class InfraConfig {
/**
* 创建适合阻塞模型及文件调用的虚拟线程执行器。
*
* @return 应用共享执行器
*/
@Bean(destroyMethod = "close")
public ExecutorService applicationExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/**
* 复用虚拟线程处理 Spring MVC 的异步响应。
*
* @param applicationExecutor 应用共享执行器
* @return MVC 异步执行器
*/
@Bean
public AsyncTaskExecutor applicationTaskExecutor(ExecutorService applicationExecutor) {
return new TaskExecutorAdapter(applicationExecutor);
}
}

View File

@@ -0,0 +1,96 @@
package cn.alphaline.smartfactory.config;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiError;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.UUID;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
/**
* 单管理员 Cookie Session 安全配置。
*/
@Configuration
public class SecurityConfig {
/**
* 创建密码编码器。
*
* @return BCrypt 编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 创建认证管理器。
*
* @param configuration Spring Security 认证配置
* @return 认证管理器
* @throws Exception 配置解析失败时抛出
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
/**
* 定义接口授权和 CSRF 规则。
*
* @param http HTTP 安全构建器
* @param userService 用户加载服务
* @param objectMapper JSON 映射器
* @return 安全过滤链
* @throws Exception 安全规则构建失败时抛出
*/
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
UserService userService,
ObjectMapper objectMapper) throws Exception {
CookieCsrfTokenRepository csrf = CookieCsrfTokenRepository.withHttpOnlyFalse();
csrf.setCookiePath("/");
return http
.userDetailsService(userService)
.csrf(configurer -> configurer
.csrfTokenRepository(csrf)
.ignoringRequestMatchers("/api/auth/login"))
.authorizeHttpRequests(registry -> registry
.requestMatchers("/api/auth/login", "/api/auth/csrf", "/", "/index.html", "/assets/**")
.permitAll()
.anyRequest().authenticated())
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, exception) -> writeError(
response, objectMapper, 401, "AUTHENTICATION_REQUIRED", "请先登录"))
.accessDeniedHandler((request, response, exception) -> writeError(
response, objectMapper, 403, "ACCESS_DENIED", "当前请求无权执行")))
.requestCache(cache -> cache.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.logout(logout -> logout.logoutUrl("/api/auth/logout").logoutSuccessHandler((request, response, authentication) -> response.setStatus(204)))
.build();
}
private void writeError(
jakarta.servlet.http.HttpServletResponse response,
ObjectMapper objectMapper,
int status,
String code,
String message) throws java.io.IOException {
response.setStatus(status);
response.setCharacterEncoding(java.nio.charset.StandardCharsets.UTF_8.name());
response.setContentType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
new ApiError(code, message, UUID.randomUUID().toString(), Instant.now()));
}
}

View File

@@ -0,0 +1,31 @@
package cn.alphaline.smartfactory.config;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* AgentScope PostgreSQL Skill Repository 配置。
*/
@Configuration
public class SkillRepositoryConfig {
/**
* 创建由 Flyway 管理表结构的 Skill 仓库。
*
* @param dataSource 数据源
* @return 可写 Skill 仓库
*/
@Bean(destroyMethod = "close")
public PostgresSkillRepository postgresSkillRepository(DataSource dataSource) {
return PostgresSkillRepository.builder(dataSource)
.schemaName("agentscope")
.skillsTableName("agentscope_skills")
.resourcesTableName("agentscope_skill_resources")
.createIfNotExist(false)
.writeable(true)
.build();
}
}

View File

@@ -0,0 +1,84 @@
package cn.alphaline.smartfactory.model;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* 使用 AES-GCM 加密数据库中的模型密钥。
*/
@Component
public class KeyCipher {
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
/**
* 创建密钥加密器。
*
* @param properties 应用配置
*/
public KeyCipher(AppProperties properties) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(properties.masterKey().getBytes(StandardCharsets.UTF_8));
this.key = new SecretKeySpec(digest, "AES");
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("无法初始化密钥加密器", exception);
}
}
/**
* 加密明文密钥。
*
* @param plaintext 明文
* @return IV 与密文组合字节
*/
public byte[] encrypt(String plaintext) {
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
return ByteBuffer.allocate(iv.length + ciphertext.length).put(iv).put(ciphertext).array();
} catch (GeneralSecurityException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_ENCRYPT_FAILED", "模型密钥加密失败");
}
}
/**
* 解密数据库密钥。
*
* @param encrypted IV 与密文组合字节
* @return 明文密钥
*/
public String decrypt(byte[] encrypted) {
if (encrypted == null || encrypted.length <= IV_LENGTH) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_MISSING", "模型密钥未配置");
}
byte[] iv = Arrays.copyOfRange(encrypted, 0, IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(encrypted, IV_LENGTH, encrypted.length);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (GeneralSecurityException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_DECRYPT_FAILED", "模型密钥解密失败");
}
}
}

View File

@@ -0,0 +1,96 @@
package cn.alphaline.smartfactory.model;
import jakarta.validation.Valid;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 提供模型配置与连接测试接口。
*/
@RestController
@RequestMapping("/api/models")
public class ModelController {
private final ModelService modelService;
/**
* 创建模型控制器。
*
* @param modelService 模型服务
*/
public ModelController(ModelService modelService) {
this.modelService = modelService;
}
/**
* 列出模型。
*
* @return 模型列表
*/
@GetMapping
public List<ModelService.ModelView> list() {
return modelService.list();
}
/**
* 新增模型。
*
* @param input 模型输入
* @param principal 当前用户
* @return 新模型
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ModelService.ModelView create(@Valid @RequestBody ModelService.ModelInput input, Principal principal) {
return modelService.save(null, input, principal);
}
/**
* 更新模型。
*
* @param id 模型 ID
* @param input 模型输入
* @param principal 当前用户
* @return 更新后的模型
*/
@PutMapping("/{id}")
public ModelService.ModelView update(
@PathVariable UUID id,
@Valid @RequestBody ModelService.ModelInput input,
Principal principal) {
return modelService.save(id, input, principal);
}
/**
* 测试模型连接。
*
* @param id 模型 ID
* @return 测试结果
*/
@PostMapping("/{id}/test")
public ModelService.ConnectionResult test(@PathVariable UUID id) {
return modelService.test(id);
}
/**
* 设置默认模型。
*
* @param id 模型 ID
* @param principal 当前用户
*/
@PostMapping("/{id}/default")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void setDefault(@PathVariable UUID id, Principal principal) {
modelService.setDefault(id, principal);
}
}

View File

@@ -0,0 +1,452 @@
package cn.alphaline.smartfactory.model;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Principal;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
*/
@Service
@Order(2)
public class ModelService implements ApplicationRunner {
private final JdbcClient jdbc;
private final UserService userService;
private final KeyCipher keyCipher;
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
/**
* 创建模型服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param properties 应用配置
* @param objectMapper JSON 映射器
*/
public ModelService(
JdbcClient jdbc,
UserService userService,
KeyCipher keyCipher,
AppProperties properties,
ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.userService = userService;
this.keyCipher = keyCipher;
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
/**
* 从项目根目录 Key 文件初始化默认模型。
*
* @param args 启动参数
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
return;
}
try {
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
if (key.isBlank()) {
return;
}
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
.query(UUID.class)
.single();
UUID modelId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, is_default, created_by)
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
""")
.param("id", modelId)
.param("baseUrl", properties.modelBaseUrl())
.param("modelId", properties.modelId())
.param("ciphertext", keyCipher.encrypt(key))
.param("hint", hint(key))
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
.param("capabilities", json(Map.of(
"toolCalling", true,
"reasoning", true,
"contextWindow", properties.modelContextWindow())))
.param("userId", adminId)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
.param("role", role)
.param("id", modelId)
.param("userId", adminId)
.update();
}
} catch (IOException exception) {
throw new IllegalStateException("无法读取默认模型 Key", exception);
}
}
/**
* 列出模型配置,永不返回明文密钥。
*
* @return 模型列表
*/
public List<ModelView> list() {
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
.query(ModelService::mapModel)
.list();
}
/**
* 保存新增或已有模型配置。
*
* @param id 可选模型 ID
* @param input 模型输入
* @param principal 当前用户
* @return 保存后的模型
*/
@Transactional
public ModelView save(UUID id, ModelInput input, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
contextWindow(input.capabilities());
if (id == null) {
if (input.apiKey() == null || input.apiKey().isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
}
id = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, created_by)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
""")
.param("id", id)
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("userId", userId)
.update();
} else {
int updated = input.apiKey() == null || input.apiKey().isBlank()
? jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update()
: jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
}
return require(id);
}
/**
* 将模型设置为所有角色默认模型。
*
* @param id 模型 ID
* @param principal 当前用户
*/
@Transactional
public void setDefault(UUID id, Principal principal) {
require(id);
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
.param("id", id)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("""
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
VALUES (:role, :id, :userId)
ON CONFLICT (role) DO UPDATE
SET model_config_id = EXCLUDED.model_config_id,
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
""")
.param("role", role)
.param("id", id)
.param("userId", userId)
.update();
}
}
/**
* 使用最小 Chat Completion 请求测试连接。
*
* @param id 模型 ID
* @return 测试结果
*/
public ConnectionResult test(UUID id) {
ModelSecret model = requireSecret(id);
String requestJson = json(Map.of(
"model", model.modelId(),
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
"max_tokens", 8,
"stream", false));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(model.baseUrl() + "/chat/completions"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + model.apiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
long started = System.nanoTime();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
long elapsed = Duration.ofNanos(System.nanoTime() - started).toMillis();
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED",
"模型连接失败,服务返回 HTTP " + response.statusCode());
}
return new ConnectionResult(true, elapsed, "连接正常");
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED", "无法连接模型服务");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "MODEL_CONNECTION_INTERRUPTED", "模型连接测试已中断");
}
}
/**
* 获取当前默认模型及明文 Key仅供模型调用。
*
* @return 默认模型机密配置
*/
public ModelSecret defaultModelSecret() {
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
return requireSecret(id);
}
private ModelView require(UUID id) {
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
.param("id", id)
.query(ModelService::mapModel)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
}
private ModelSecret requireSecret(UUID id) {
return jdbc.sql("""
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
FROM app.model_config WHERE id = :id AND enabled
""")
.param("id", id)
.query((rs, rowNum) -> new ModelSecret(
rs.getObject("id", UUID.class),
rs.getString("base_url"),
rs.getString("model_id"),
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
}
/**
* 读取并校验模型上下文窗口。
*
* @param capabilities 模型能力配置
* @return 上下文 Token 上限
*/
private int contextWindow(Map<String, Object> capabilities) {
Object value = capabilities == null ? null : capabilities.get("contextWindow");
if (!(value instanceof Number number) || number.intValue() < 8_192) {
throw new ApiException(
HttpStatus.BAD_REQUEST,
"MODEL_CONTEXT_WINDOW_INVALID",
"上下文窗口不能小于 8192 Token");
}
return number.intValue();
}
/**
* 解析数据库中的模型能力配置。
*
* @param json 能力 JSON
* @return 能力键值
*/
@SuppressWarnings("unchecked")
private Map<String, Object> parseCapabilities(String json) {
try {
return objectMapper.readValue(json, Map.class);
} catch (IOException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MODEL_CAPABILITIES_INVALID",
"模型能力配置无法读取");
}
}
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ModelView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("provider"),
rs.getString("base_url"),
rs.getString("model_id"),
rs.getString("api_key_hint"),
rs.getString("config_json"),
rs.getString("capabilities_json"),
rs.getBoolean("enabled"),
rs.getBoolean("is_default"),
rs.getObject("updated_at", OffsetDateTime.class));
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化");
}
}
private String normalizeBaseUrl(String baseUrl) {
String value = baseUrl.trim();
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
private static String hint(String key) {
return "••••" + key.substring(Math.max(0, key.length() - 4));
}
private static final String MODEL_SELECT = """
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
capabilities_json, enabled, is_default, updated_at
FROM app.model_config
""";
/**
* 模型编辑输入。
*
* @param name 配置名称
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKey 新密钥;空值表示保留
* @param config 高级配置
* @param capabilities 能力声明
*/
public record ModelInput(
@NotBlank String name,
@NotBlank String baseUrl,
@NotBlank String modelId,
String apiKey,
Map<String, Object> config,
Map<String, Object> capabilities) {
}
/**
* 对外模型视图。
*
* @param id 模型 ID
* @param name 配置名称
* @param provider 服务商
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKeyHint 密钥遮罩
* @param configJson 高级配置
* @param capabilitiesJson 能力配置
* @param enabled 是否启用
* @param defaultModel 是否默认
* @param updatedAt 更新时间
*/
public record ModelView(
UUID id,
String name,
String provider,
String baseUrl,
String modelId,
String apiKeyHint,
String configJson,
String capabilitiesJson,
boolean enabled,
boolean defaultModel,
OffsetDateTime updatedAt) {
}
/**
* 内部模型机密配置。
*
* @param id 模型 ID
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKey 明文密钥
* @param contextWindow 上下文 Token 上限
*/
public record ModelSecret(UUID id, String baseUrl, String modelId, String apiKey, int contextWindow) {
}
/**
* 连接测试结果。
*
* @param success 是否成功
* @param latencyMs 往返耗时
* @param message 状态说明
*/
public record ConnectionResult(boolean success, long latencyMs, String message) {
}
}

View File

@@ -0,0 +1,213 @@
package cn.alphaline.smartfactory.project;
import cn.alphaline.smartfactory.agent.AgentRunService;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 提供项目、规划和企业材料接口。
*/
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
private final ProjectService projectService;
private final ProjectFileService fileService;
private final AgentRunService runService;
/**
* 创建项目控制器。
*
* @param projectService 项目服务
* @param fileService 文件服务
* @param runService Agent Run 服务
*/
public ProjectController(
ProjectService projectService,
ProjectFileService fileService,
AgentRunService runService) {
this.projectService = projectService;
this.fileService = fileService;
this.runService = runService;
}
/**
* 列出项目。
*
* @return 项目列表
*/
@GetMapping
public List<ProjectService.ProjectView> list() {
return projectService.list();
}
/**
* 创建项目。
*
* @param request 创建参数
* @param principal 当前用户
* @return 新项目
*/
@PostMapping
public ProjectService.ProjectView create(@Valid @RequestBody CreateProjectRequest request, Principal principal) {
ProjectService.ProjectView project = projectService.create(
request.companyName(), request.applicationLevel(), principal);
fileService.ensureWorkspace(project.id());
return project;
}
/**
* 读取项目详情。
*
* @param projectId 项目 ID
* @return 项目详情
*/
@GetMapping("/{projectId}")
public ProjectService.ProjectView get(@PathVariable UUID projectId) {
return projectService.require(projectId);
}
/**
* 真删除项目、业务记录和工作区文件。
*
* @param projectId 项目 ID
*/
@DeleteMapping("/{projectId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID projectId) {
projectService.delete(projectId);
fileService.deleteWorkspace(projectId);
}
/**
* 返回项目当前规划。
*
* @param projectId 项目 ID
* @return 当前规划;未生成时为空响应体
*/
@GetMapping("/{projectId}/plan")
public ResponseEntity<ProjectService.PlanView> currentPlan(@PathVariable UUID projectId) {
ProjectService.PlanView plan = projectService.currentPlan(projectId);
return plan == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(plan);
}
/**
* 确认建设规划。
*
* @param projectId 项目 ID
* @param request 确认参数
* @param principal 当前用户
* @return 已确认规划与已启动的编写 Run
*/
@PostMapping("/{projectId}/plan/confirm")
public AgentRunService.ConfirmPlanResult confirmPlan(
@PathVariable UUID projectId,
@Valid @RequestBody ConfirmPlanRequest request,
Principal principal) {
return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), principal);
}
/**
* 上传企业材料。
*
* @param projectId 项目 ID
* @param file 上传文件
* @param relativePath 文件夹内相对路径
* @param principal 当前用户
* @return 文件元数据
*/
@PostMapping(path = "/{projectId}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ProjectFileService.FileView upload(
@PathVariable UUID projectId,
@RequestParam MultipartFile file,
@RequestParam(required = false) String relativePath,
Principal principal) {
return fileService.upload(projectId, file, relativePath, principal);
}
/**
* 列出企业材料。
*
* @param projectId 项目 ID
* @return 文件列表
*/
@GetMapping("/{projectId}/files")
public List<ProjectFileService.FileView> files(@PathVariable UUID projectId) {
return fileService.list(projectId);
}
/**
* 下载企业材料。
*
* @param projectId 项目 ID
* @param fileId 文件 ID
* @return 文件响应
*/
@GetMapping("/{projectId}/files/{fileId}/download")
public ResponseEntity<org.springframework.core.io.Resource> download(
@PathVariable UUID projectId,
@PathVariable UUID fileId) {
ProjectFileService.Download download = fileService.download(projectId, fileId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(download.mimeType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
.body(download.resource());
}
/**
* 返回 document_view 生成的项目内预览图。
*
* @param projectId 项目 ID
* @param path 工作区相对路径
* @return 图片响应
*/
@GetMapping("/{projectId}/view-images")
public ResponseEntity<org.springframework.core.io.Resource> preview(
@PathVariable UUID projectId,
@RequestParam String path) {
ProjectFileService.Preview preview = fileService.preview(projectId, path);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(preview.mimeType()))
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
.body(preview.resource());
}
/**
* 项目创建参数。
*
* @param companyName 企业名称
* @param applicationLevel 申报等级
*/
public record CreateProjectRequest(@NotBlank String companyName, String applicationLevel) {
}
/**
* 规划确认参数。
*
* @param planId 草稿规划 ID
* @param plan 用户确认后的完整规划
*/
public record ConfirmPlanRequest(UUID planId, JsonNode plan) {
}
}

View File

@@ -0,0 +1,438 @@
package cn.alphaline.smartfactory.project;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import org.apache.tika.Tika;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
/**
* 保存、校验和读取项目企业材料。
*/
@Service
public class ProjectFileService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
private final JdbcClient jdbc;
private final UserService userService;
private final ProjectService projectService;
private final Path dataRoot;
private final Tika tika = new Tika();
/**
* 创建材料服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param projectService 项目服务
* @param properties 应用配置
*/
public ProjectFileService(
JdbcClient jdbc,
UserService userService,
ProjectService projectService,
AppProperties properties) {
this.jdbc = jdbc;
this.userService = userService;
this.projectService = projectService;
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
}
/**
* 初始化项目工作区目录。
*
* @param projectId 项目 ID
*/
public void ensureWorkspace(UUID projectId) {
Path root = projectRoot(projectId);
try {
for (String directory : List.of(
"inputs", "work/facts", "work/plans", "work/drafts", "work/reviews", "work/tmp",
"work/candidates", "references", "artifacts", "skills")) {
Files.createDirectories(root.resolve(directory));
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE_CREATE_FAILED", "项目工作区创建失败");
}
}
/**
* 删除项目的整个受控工作区。
*
* @param projectId 项目 ID
* @throws ApiException 文件删除失败时抛出
*/
public void deleteWorkspace(UUID projectId) {
Path root = projectRoot(projectId);
if (!Files.exists(root)) {
return;
}
try (var paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE_DELETE_FAILED", "项目文件删除失败");
}
}
/**
* 上传并校验企业材料。
*
* @param projectId 项目 ID
* @param file 上传文件
* @param relativePath 浏览器提供的文件夹内相对路径;单文件上传时可为空
* @param principal 当前用户
* @return 文件元数据
*/
@Transactional
public FileView upload(UUID projectId, MultipartFile file, String relativePath, Principal principal) {
projectService.require(projectId);
String originalName = safeName(file.getOriginalFilename());
String extension = extension(originalName);
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new ApiException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE_TYPE_NOT_ALLOWED", "暂不支持该文件类型");
}
ensureWorkspace(projectId);
UUID fileId = UUID.randomUUID();
String workspacePath = normalizeUploadPath(relativePath, originalName);
Path target = safeProjectPath(projectId, workspacePath);
Path temporary = target.resolveSibling(target.getFileName() + ".uploading-" + fileId);
boolean moved = false;
try {
Files.createDirectories(target.getParent());
if (Files.exists(target)) {
throw new ApiException(HttpStatus.CONFLICT, "FILE_ALREADY_EXISTS", "文件夹中存在同名文件");
}
String mime;
try (InputStream input = file.getInputStream()) {
mime = tika.detect(input, originalName);
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (DigestInputStream input = new DigestInputStream(file.getInputStream(), digest)) {
Files.copy(input, temporary);
}
Files.move(temporary, target);
moved = true;
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("""
INSERT INTO app.project_file(
id, project_id, original_name, stored_name, relative_path, mime_type,
extension, size_bytes, sha256, uploaded_by)
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType,
:extension, :sizeBytes, :sha256, :userId)
""")
.param("id", fileId)
.param("projectId", projectId)
.param("originalName", originalName)
.param("storedName", target.getFileName().toString())
.param("relativePath", workspacePath)
.param("mimeType", mime)
.param("extension", extension)
.param("sizeBytes", Files.size(target))
.param("sha256", HexFormat.of().formatHex(digest.digest()))
.param("userId", userId)
.update();
return require(fileId);
} catch (FileAlreadyExistsException exception) {
cleanupFailedUpload(exception, temporary);
throw new ApiException(HttpStatus.CONFLICT, "FILE_ALREADY_EXISTS", "文件夹中存在同名文件");
} catch (IOException | NoSuchAlgorithmException exception) {
cleanupFailedUpload(exception, moved ? new Path[]{temporary, target} : new Path[]{temporary});
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_STORE_FAILED", "文件保存失败");
} catch (RuntimeException exception) {
cleanupFailedUpload(exception, moved ? new Path[]{temporary, target} : new Path[]{temporary});
throw exception;
}
}
/**
* 清理未完成上传留下的临时文件或孤儿正式文件。
*
* @param failure 原始异常
* @param paths 待清理路径
*/
private void cleanupFailedUpload(Throwable failure, Path... paths) {
for (Path path : paths) {
try {
Files.deleteIfExists(path);
} catch (IOException cleanupException) {
failure.addSuppressed(cleanupException);
}
}
}
/**
* 列出项目有效材料。
*
* @param projectId 项目 ID
* @return 文件元数据列表
*/
public List<FileView> list(UUID projectId) {
projectService.require(projectId);
return jdbc.sql("""
SELECT id, project_id, original_name, relative_path, mime_type, extension,
size_bytes, status, created_at
FROM app.project_file
WHERE project_id = :projectId AND deleted_at IS NULL
ORDER BY relative_path
""")
.param("projectId", projectId)
.query(ProjectFileService::mapFile)
.list();
}
/**
* 获取材料下载资源。
*
* @param projectId 项目 ID
* @param fileId 文件 ID
* @return 文件资源
*/
public Download download(UUID projectId, UUID fileId) {
StoredFile stored = jdbc.sql("""
SELECT original_name, relative_path, mime_type
FROM app.project_file
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY'
""")
.param("fileId", fileId)
.param("projectId", projectId)
.query((rs, rowNum) -> new StoredFile(
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type")))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
try {
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
if (!resource.exists()) {
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件内容不存在");
}
return new Download(stored.originalName(), stored.mimeType(), resource);
} catch (java.net.MalformedURLException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_PATH_INVALID", "文件路径无效");
}
}
/**
* 读取 document_view 生成的项目内预览图。
*
* @param projectId 项目 ID
* @param relativePath 工作区相对路径
* @return 预览图资源
* @throws ApiException 路径非法或图片不存在时抛出
*/
public Preview preview(UUID projectId, String relativePath) {
projectService.require(projectId);
String normalized = relativePath == null ? "" : relativePath.replace('\\', '/');
if (!normalized.startsWith("work/tmp/document-view/")
|| !(normalized.endsWith(".png") || normalized.endsWith(".jpg") || normalized.endsWith(".jpeg"))) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PREVIEW_PATH_INVALID", "预览图路径无效");
}
Path path = safeProjectPath(projectId, normalized);
if (!Files.isRegularFile(path)) {
throw new ApiException(HttpStatus.NOT_FOUND, "PREVIEW_NOT_FOUND", "预览图不存在");
}
try {
String mimeType = Files.probeContentType(path);
return new Preview(mimeType == null ? "image/png" : mimeType, new UrlResource(path.toUri()));
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "PREVIEW_READ_FAILED", "预览图读取失败");
}
}
/**
* 返回规范化项目根目录。
*
* @param projectId 项目 ID
* @return 项目根目录
*/
public Path projectRoot(UUID projectId) {
return dataRoot.resolve("projects").resolve(projectId.toString()).normalize();
}
/**
* 在项目根目录内解析相对路径。
*
* @param projectId 项目 ID
* @param relativePath 相对路径
* @return 安全绝对路径
*/
public Path safeProjectPath(UUID projectId, String relativePath) {
Path root = projectRoot(projectId);
Path result = root.resolve(relativePath).normalize();
if (!result.startsWith(root)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PATH_OUTSIDE_PROJECT", "文件路径超出项目范围");
}
verifyExistingParent(root, result);
return result;
}
/**
* 拒绝通过现有符号链接把后续路径解析到项目目录之外。
*
* @param root 项目根目录
* @param result 待使用路径
*/
private void verifyExistingParent(Path root, Path result) {
try {
Path existing = result;
while (existing != null && !Files.exists(existing)) {
existing = existing.getParent();
}
if (existing != null && !existing.toRealPath().startsWith(root.toRealPath())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PATH_OUTSIDE_PROJECT", "文件路径超出项目范围");
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件路径无效");
}
}
private FileView require(UUID fileId) {
return jdbc.sql("""
SELECT id, project_id, original_name, relative_path, mime_type, extension,
size_bytes, status, created_at
FROM app.project_file WHERE id = :id
""")
.param("id", fileId)
.query(ProjectFileService::mapFile)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
}
private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new FileView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("original_name"),
rs.getString("relative_path"),
rs.getString("mime_type"),
rs.getString("extension"),
rs.getLong("size_bytes"),
rs.getString("status"),
rs.getObject("created_at", OffsetDateTime.class));
}
private String safeName(String originalName) {
if (originalName == null || originalName.isBlank()) {
return "未命名文件";
}
return Path.of(originalName).getFileName().toString().replaceAll("[\\r\\n]", "_");
}
/**
* 规范化上传路径并保留用户选择的文件夹层级。
*
* @param suppliedPath 浏览器提供的文件夹内路径
* @param originalName 原始文件名
* @return 相对于项目根目录的 inputs 路径
* @throws ApiException 路径越界、过长或文件名不一致时抛出
*/
String normalizeUploadPath(String suppliedPath, String originalName) {
String candidate = suppliedPath == null || suppliedPath.isBlank()
? originalName
: suppliedPath.replace('\\', '/');
if (candidate.startsWith("/") || candidate.indexOf('\0') >= 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
Path path;
try {
path = Path.of(candidate).normalize();
} catch (RuntimeException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
if (path.isAbsolute() || path.getNameCount() == 0 || path.startsWith("..")
|| !path.getFileName().toString().equals(originalName)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
for (Path segment : path) {
String value = segment.toString();
if (value.isBlank() || ".".equals(value) || "..".equals(value)
|| value.getBytes(StandardCharsets.UTF_8).length > 255) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
}
String result = "inputs/" + path.toString().replace('\\', '/');
if (result.length() > 1_000 || originalName.length() > 500) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_TOO_LONG", "文件夹路径过长");
}
return result;
}
private String extension(String name) {
int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
/**
* 企业材料元数据。
*
* @param id 文件 ID
* @param projectId 项目 ID
* @param name 原始文件名
* @param relativePath 工作区相对路径
* @param mimeType 检测到的 MIME
* @param extension 扩展名
* @param sizeBytes 文件大小
* @param status 文件状态
* @param createdAt 上传时间
*/
public record FileView(
UUID id,
UUID projectId,
String name,
String relativePath,
String mimeType,
String extension,
long sizeBytes,
String status,
OffsetDateTime createdAt) {
}
/**
* 下载结果。
*
* @param name 下载文件名
* @param mimeType MIME 类型
* @param resource 文件资源
*/
public record Download(String name, String mimeType, Resource resource) {
}
/**
* 文档视觉预览结果。
*
* @param mimeType 图片 MIME 类型
* @param resource 图片资源
*/
public record Preview(String mimeType, Resource resource) {
}
private record StoredFile(String originalName, String relativePath, String mimeType) {
}
}

View File

@@ -0,0 +1,326 @@
package cn.alphaline.smartfactory.project;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 管理企业项目和不可变规划版本。
*/
@Service
public class ProjectService {
private final JdbcClient jdbc;
private final UserService userService;
private final ObjectMapper objectMapper;
/**
* 创建项目服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param objectMapper JSON 映射器
*/
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.userService = userService;
this.objectMapper = objectMapper;
}
/**
* 创建企业申报项目。
*
* @param companyName 企业名称
* @param applicationLevel 申报等级
* @param principal 当前用户
* @return 新项目
*/
@Transactional
public ProjectView create(String companyName, String applicationLevel, Principal principal) {
String level = normalizeLevel(applicationLevel);
UUID id = UUID.randomUUID();
UUID userId = userService.requireUserId(principal.getName());
String threadId = "project-" + id;
jdbc.sql("""
INSERT INTO app.project(
id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId)
""")
.param("id", id)
.param("companyName", companyName.trim())
.param("projectName", companyName.trim())
.param("threadId", threadId)
.param("level", level)
.param("userId", userId)
.update();
return require(id);
}
/**
* 列出最近项目。
*
* @return 按更新时间倒序的项目
*/
public List<ProjectView> list() {
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC")
.query(ProjectService::mapProject)
.list();
}
/**
* 读取一个项目。
*
* @param projectId 项目 ID
* @return 项目详情
* @throws ApiException 项目不存在时抛出
*/
public ProjectView require(UUID projectId) {
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id")
.param("id", projectId)
.query(ProjectService::mapProject)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"));
}
/**
* 真删除项目及其全部业务记录。
*
* @param projectId 项目 ID
* @throws ApiException 项目不存在或仍有 Agent 正在执行时抛出
*/
@Transactional
public void delete(UUID projectId) {
require(projectId);
boolean running = jdbc.sql("""
SELECT EXISTS(
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
)
""")
.param("projectId", projectId)
.query(Boolean.class)
.single();
if (running) {
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
}
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId")
.param("projectId", projectId)
.update();
}
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId")
.param("projectId", projectId)
.update();
if (deleted != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
}
/**
* 更新项目业务阶段。
*
* @param projectId 项目 ID
* @param status 新阶段
*/
public void updateStatus(UUID projectId, String status) {
int updated = jdbc.sql("""
UPDATE app.project
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("status", status)
.param("id", projectId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
}
/**
* 保存 Agent 生成的规划草稿。
*
* @param projectId 项目 ID
* @param plan 规划 JSON
* @param userId 创建人
* @return 规划版本
*/
@Transactional
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
Integer version = jdbc.sql("SELECT COALESCE(MAX(plan_version), 0) + 1 FROM app.project_plan WHERE project_id = :id")
.param("id", projectId)
.query(Integer.class)
.single();
UUID planId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
VALUES (:id, :projectId, :version, 'DRAFT', CAST(:plan AS jsonb), :userId)
""")
.param("id", planId)
.param("projectId", projectId)
.param("version", version)
.param("plan", plan.toString())
.param("userId", userId)
.update();
updateStatus(projectId, "PLANNING");
return requirePlan(planId);
}
/**
* 返回项目当前规划。
*
* @param projectId 项目 ID
* @return 最新规划;不存在时返回空
*/
public PlanView currentPlan(UUID projectId) {
return jdbc.sql("""
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
FROM app.project_plan
WHERE project_id = :projectId
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
LIMIT 1
""")
.param("projectId", projectId)
.query(this::mapPlan)
.optional()
.orElse(null);
}
/**
* 确认规划并冻结其内容。
*
* @param projectId 项目 ID
* @param planId 草稿规划 ID
* @param plan 用户确认后的完整规划
* @param principal 当前用户
* @return 已确认规划
*/
@Transactional
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
int updated = jdbc.sql("""
UPDATE app.project_plan
SET status = 'CONFIRMED', plan_json = CAST(:plan AS jsonb), confirmed_by = :userId,
confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :planId AND project_id = :projectId AND status = 'DRAFT'
""")
.param("plan", plan.toString())
.param("userId", userId)
.param("planId", planId)
.param("projectId", projectId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
}
updateStatus(projectId, "WRITING");
return requirePlan(planId);
}
private PlanView requirePlan(UUID planId) {
return jdbc.sql("""
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
FROM app.project_plan WHERE id = :id
""")
.param("id", planId)
.query(this::mapPlan)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PLAN_NOT_FOUND", "规划不存在"));
}
private PlanView mapPlan(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
try {
return new PlanView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getInt("plan_version"),
rs.getString("status"),
objectMapper.readTree(rs.getString("plan_json")),
rs.getObject("confirmed_at", OffsetDateTime.class),
rs.getObject("created_at", OffsetDateTime.class));
} catch (JsonProcessingException exception) {
throw new java.sql.SQLException("规划 JSON 无法解析", exception);
}
}
private static ProjectView mapProject(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ProjectView(
rs.getObject("id", UUID.class),
rs.getString("company_name"),
rs.getString("project_name"),
rs.getString("agui_thread_id"),
rs.getString("application_level"),
rs.getString("status"),
rs.getLong("version"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class));
}
private String normalizeLevel(String level) {
String value = level == null ? "ADVANCED" : level.trim().toUpperCase(java.util.Locale.ROOT);
if (!value.equals("ADVANCED") && !value.equals("EXCELLENT")) {
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_APPLICATION_LEVEL", "申报等级无效");
}
return value;
}
private static final String PROJECT_SELECT = """
SELECT id, company_name, project_name, agui_thread_id, application_level, status,
version, created_at, updated_at
FROM app.project
""";
/**
* 项目视图。
*
* @param id 项目 ID
* @param companyName 企业名称
* @param projectName 项目名称
* @param threadId AG-UI 线程 ID
* @param applicationLevel 申报等级
* @param status 当前状态
* @param version 乐观锁版本
* @param createdAt 创建时间
* @param updatedAt 更新时间
*/
public record ProjectView(
UUID id,
String companyName,
String projectName,
String threadId,
String applicationLevel,
String status,
long version,
OffsetDateTime createdAt,
OffsetDateTime updatedAt) {
}
/**
* 规划版本视图。
*
* @param id 规划 ID
* @param projectId 项目 ID
* @param version 版本号
* @param status 规划状态
* @param plan 规划 JSON
* @param confirmedAt 确认时间
* @param createdAt 创建时间
*/
public record PlanView(
UUID id,
UUID projectId,
int version,
String status,
JsonNode plan,
OffsetDateTime confirmedAt,
OffsetDateTime createdAt) {
}
}

View File

@@ -0,0 +1,113 @@
package cn.alphaline.smartfactory.skill;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 提供 Skill 导入、查看和启停接口。
*/
@RestController
@RequestMapping("/api/skills")
public class SkillController {
private final SkillService skillService;
/**
* 创建 Skill 控制器。
*
* @param skillService Skill 服务
*/
public SkillController(SkillService skillService) {
this.skillService = skillService;
}
/**
* 列出 Skill。
*
* @return Skill 列表
*/
@GetMapping
public List<SkillService.SkillView> list() {
return skillService.list();
}
/**
* 读取 Skill 详情。
*
* @param name Skill 名称
* @return Skill 详情
*/
@GetMapping("/{name}")
public SkillService.SkillDetail get(@PathVariable String name) {
return skillService.require(name);
}
/**
* 读取一个文本资源。
*
* @param name Skill 名称
* @param path 资源路径
* @return 资源正文
*/
@GetMapping("/{name}/resource")
public String resource(@PathVariable String name, @RequestParam String path) {
return skillService.resource(name, path);
}
/**
* 导入 Skill ZIP。
*
* @param file ZIP 文件
* @param principal 当前用户
* @return 导入后的 Skill
*/
@PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public SkillService.SkillView importZip(@RequestParam MultipartFile file, Principal principal) {
return skillService.importZip(file, principal);
}
/**
* 启用 Skill。
*
* @param name Skill 名称
*/
@PostMapping("/{name}/enable")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void enable(@PathVariable String name) {
skillService.setEnabled(name, true);
}
/**
* 停用 Skill。
*
* @param name Skill 名称
*/
@PostMapping("/{name}/disable")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void disable(@PathVariable String name) {
skillService.setEnabled(name, false);
}
/**
* 删除用户导入 Skill。
*
* @param name Skill 名称
*/
@DeleteMapping("/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String name) {
skillService.deleteImported(name);
}
}

View File

@@ -0,0 +1,149 @@
package cn.alphaline.smartfactory.skill;
import cn.alphaline.smartfactory.common.ApiException;
import io.agentscope.core.skill.AgentSkill;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* 读取并校验标准 Skill 目录。
*/
@Component
public class SkillPackageReader {
private static final Pattern FRONTMATTER = Pattern.compile("\\A---\\s*\\R(.*?)\\R---\\s*\\R", Pattern.DOTALL);
private static final Pattern FIELD = Pattern.compile("(?m)^([A-Za-z][A-Za-z0-9_-]*):\\s*(.+?)\\s*$");
/**
* 读取一个 Skill 目录。
*
* @param directory Skill 根目录
* @param source 仓库来源
* @return 已校验 Skill 包
*/
public SkillPackage read(Path directory, String source) {
Path root = directory.toAbsolutePath().normalize();
Path skillFile = root.resolve("SKILL.md");
if (!Files.isRegularFile(skillFile)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FILE_MISSING", "Skill 缺少 SKILL.md");
}
try {
List<Path> files = Files.walk(root)
.filter(Files::isRegularFile)
.filter(path -> !isHidden(root.relativize(path)))
.sorted(Comparator.comparing(path -> normalize(root.relativize(path))))
.toList();
if (files.size() > 500) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_TOO_MANY_FILES", "Skill 文件数量超过限制");
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Map<String, String> resources = new HashMap<>();
String content = null;
for (Path file : files) {
String relative = normalize(root.relativize(file));
byte[] bytes = Files.readAllBytes(file);
String text = decodeUtf8(bytes, relative);
digest.update(relative.getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(bytes);
if (relative.equals("SKILL.md")) {
content = text;
} else {
resources.put(relative, text);
}
}
Map<String, String> frontmatter = frontmatter(content);
String name = required(frontmatter, "name");
String description = required(frontmatter, "description");
String version = frontmatter.getOrDefault("version", "v1.0");
AgentSkill skill = new AgentSkill(name, description, content, resources, source);
return new SkillPackage(skill, version, HexFormat.of().formatHex(digest.digest()), files.size());
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_READ_FAILED", "Skill 文件读取失败");
}
}
private Map<String, String> frontmatter(String content) {
Matcher block = FRONTMATTER.matcher(content == null ? "" : content);
if (!block.find()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FRONTMATTER_MISSING", "SKILL.md 缺少 YAML Frontmatter");
}
Map<String, String> values = new HashMap<>();
Matcher field = FIELD.matcher(block.group(1));
while (field.find()) {
values.put(field.group(1), unquote(field.group(2).trim()));
}
return values;
}
private String required(Map<String, String> values, String key) {
String value = values.get(key);
if (value == null || value.isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FRONTMATTER_INVALID", "SKILL.md 缺少 " + key);
}
return value;
}
private String decodeUtf8(byte[] bytes, String path) {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_BINARY_RESOURCE", "Skill 资源必须是 UTF-8 文本:" + path);
}
}
private boolean isHidden(Path relative) {
for (Path part : relative) {
if (part.toString().startsWith(".")) {
return true;
}
}
return false;
}
private String normalize(Path path) {
return path.toString().replace('\\', '/');
}
private String unquote(String value) {
if (value.length() >= 2
&& ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'")))) {
return value.substring(1, value.length() - 1);
}
return value;
}
/**
* 已解析 Skill 包。
*
* @param skill AgentScope Skill
* @param version 展示版本
* @param checksum 目录校验和
* @param fileCount 文件数
*/
public record SkillPackage(AgentSkill skill, String version, String checksum, int fileCount) {
}
}

View File

@@ -0,0 +1,368 @@
package cn.alphaline.smartfactory.skill;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
/**
* 提供 Skill 查看、导入和启停能力。
*/
@Service
public class SkillService {
private static final int MAX_ZIP_ENTRIES = 500;
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
private final JdbcClient jdbc;
private final PostgresSkillRepository repository;
private final SkillPackageReader packageReader;
private final UserService userService;
private final AppProperties properties;
/**
* 创建 Skill 服务。
*
* @param jdbc JDBC 客户端
* @param repository AgentScope PostgreSQL 仓库
* @param packageReader Skill 包读取器
* @param userService 用户服务
* @param properties 应用配置
*/
public SkillService(
JdbcClient jdbc,
PostgresSkillRepository repository,
SkillPackageReader packageReader,
UserService userService,
AppProperties properties) {
this.jdbc = jdbc;
this.repository = repository;
this.packageReader = packageReader;
this.userService = userService;
this.properties = properties;
}
/**
* 列出 Skill 配置。
*
* @return Skill 列表
*/
public List<SkillView> list() {
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name")
.query(SkillService::mapSkill)
.list();
}
/**
* 读取 Skill 详情。
*
* @param name Skill 名称
* @return Skill 详情
*/
public SkillDetail require(String name) {
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name")
.param("name", name)
.query(SkillService::mapSkill)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
AgentSkill skill = repository.getSkill(name);
if (skill == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
}
return new SkillDetail(view, skill.getSkillContent(), skill.getResourcePaths().stream().sorted().toList());
}
/**
* 读取 Skill 文本资源。
*
* @param name Skill 名称
* @param path 资源相对路径
* @return 资源文本
*/
public String resource(String name, String path) {
if (path == null || path.isBlank() || path.startsWith("/") || path.contains("..")) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_RESOURCE_PATH_INVALID", "Skill 资源路径无效");
}
AgentSkill skill = repository.getSkill(name);
String resource = skill == null ? null : skill.getResource(path);
if (resource == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_RESOURCE_NOT_FOUND", "Skill 资源不存在");
}
return resource;
}
/**
* 设置 Skill 启用状态。
*
* @param name Skill 名称
* @param enabled 是否启用
*/
public void setEnabled(String name, boolean enabled) {
int updated = jdbc.sql("""
UPDATE app.skill_config SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP
WHERE skill_name = :name AND validation_status = 'VALID'
""")
.param("enabled", enabled)
.param("name", name)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
}
}
/**
* 返回当前启用的 Skill 名称。
*
* @return Skill 名称数组
*/
public String[] enabledNames() {
return jdbc.sql("""
SELECT skill_name FROM app.skill_config
WHERE enabled AND validation_status = 'VALID'
ORDER BY skill_name
""")
.query(String.class)
.list()
.toArray(String[]::new);
}
/**
* 导入管理员上传的标准 Skill ZIP。
*
* @param file ZIP 文件
* @param principal 当前用户
* @return 导入后的 Skill
*/
@Transactional
public SkillView importZip(MultipartFile file, Principal principal) {
if (file.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_EMPTY", "Skill 压缩包为空");
}
Path importRoot = properties.dataRoot().toAbsolutePath().normalize().resolve("skill-imports");
try {
Files.createDirectories(importRoot);
Path temporary = Files.createTempDirectory(importRoot, "skill-");
try {
unzip(file, temporary);
Path skillRoot = locateSkillRoot(temporary);
SkillPackageReader.SkillPackage skillPackage = packageReader.read(skillRoot, "imported");
String name = skillPackage.skill().getName();
if (repository.skillExists(name)) {
throw new ApiException(HttpStatus.CONFLICT, "SKILL_NAME_CONFLICT", "同名 Skill 已存在");
}
repository.save(List.of(skillPackage.skill()), false);
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("""
INSERT INTO app.skill_config(
skill_name, version, source_type, enabled, read_only, checksum,
validation_status, imported_by)
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId)
""")
.param("name", name)
.param("version", skillPackage.version())
.param("checksum", skillPackage.checksum())
.param("userId", userId)
.update();
return require(name).view();
} finally {
deleteTree(temporary);
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_IMPORT_FAILED", "Skill 压缩包读取失败");
}
}
/**
* 删除管理员导入且已停用的 Skill。
*
* @param name Skill 名称
*/
@Transactional
public void deleteImported(String name) {
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name")
.param("name", name)
.query(String.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
if (!"IMPORTED".equals(sourceType)) {
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
}
repository.delete(name);
}
/**
* 解压 Skill 包,并忽略 macOS 与 Python 生成的无关元数据。
*
* @param file ZIP 文件
* @param destination 解压目录
* @throws IOException ZIP 读取或文件写入失败时抛出
*/
static void unzip(MultipartFile file, Path destination) throws IOException {
int entries = 0;
long total = 0;
Path root = destination.toAbsolutePath().normalize();
try (InputStream source = file.getInputStream(); ZipInputStream zip = new ZipInputStream(source, java.nio.charset.StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = zip.getNextEntry()) != null) {
String entryName = entry.getName().replace('\\', '/');
Path target = root.resolve(entryName).normalize();
if (!target.startsWith(root)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_PATH_INVALID", "Skill 压缩包包含越界路径");
}
if (isIgnoredArchiveEntry(entryName)) {
continue;
}
if (++entries > MAX_ZIP_ENTRIES) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_TOO_MANY_FILES", "Skill 压缩包文件数量超过限制");
}
if (entry.isDirectory()) {
Files.createDirectories(target);
continue;
}
Files.createDirectories(target.getParent());
try (java.io.OutputStream output = Files.newOutputStream(target)) {
int read;
while ((read = zip.read(buffer)) != -1) {
total += read;
if (total > MAX_UNCOMPRESSED_BYTES) {
throw new ApiException(HttpStatus.PAYLOAD_TOO_LARGE, "SKILL_ZIP_TOO_LARGE", "Skill 解压后大小超过限制");
}
output.write(buffer, 0, read);
}
}
}
}
}
/**
* 根据唯一的 SKILL.md 定位 Skill 根目录。
*
* @param temporary ZIP 解压目录
* @return Skill 根目录
* @throws IOException 目录遍历失败时抛出
*/
static Path locateSkillRoot(Path temporary) throws IOException {
if (Files.isRegularFile(temporary.resolve("SKILL.md"))) {
return temporary;
}
List<Path> candidates;
try (var paths = Files.walk(temporary)) {
candidates = paths
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().equals("SKILL.md"))
.map(Path::getParent)
.distinct()
.toList();
}
if (candidates.size() == 1) {
return candidates.getFirst();
}
if (candidates.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_STRUCTURE_INVALID", "压缩包中必须包含 SKILL.md");
}
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_STRUCTURE_INVALID", "压缩包中包含多个 Skill请每次仅导入一个");
}
private static boolean isIgnoredArchiveEntry(String entryName) {
String lowerName = entryName.toLowerCase(Locale.ROOT);
if (lowerName.endsWith(".pyc")) {
return true;
}
for (String part : entryName.split("/")) {
if (part.equals("__MACOSX")
|| part.equals(".DS_Store")
|| part.equals("__pycache__")
|| part.startsWith("._")) {
return true;
}
}
return false;
}
private void deleteTree(Path root) {
if (root == null || !Files.exists(root)) {
return;
}
try {
for (Path path : Files.walk(root).sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
} catch (IOException ignored) {
// 临时目录清理失败不覆盖主要导入结果,后续维护任务可清理。
}
}
private static SkillView mapSkill(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new SkillView(
rs.getString("name"),
rs.getString("description"),
rs.getString("version"),
rs.getString("source_type"),
rs.getBoolean("enabled"),
rs.getBoolean("read_only"),
rs.getString("validation_status"),
rs.getString("validation_message"),
rs.getObject("updated_at", OffsetDateTime.class));
}
private static final String SKILL_SELECT = """
SELECT s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
c.validation_status, c.validation_message, c.updated_at
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name
""";
/**
* Skill 列表视图。
*
* @param name 标准名称
* @param description 描述
* @param version 版本
* @param sourceType 来源
* @param enabled 是否启用
* @param readOnly 是否只读
* @param validationStatus 校验状态
* @param validationMessage 校验信息
* @param updatedAt 更新时间
*/
public record SkillView(
String name,
String description,
String version,
String sourceType,
boolean enabled,
boolean readOnly,
String validationStatus,
String validationMessage,
OffsetDateTime updatedAt) {
}
/**
* Skill 详情。
*
* @param view 基本信息
* @param content SKILL.md 全文
* @param resources 资源路径
*/
public record SkillDetail(SkillView view, String content, List<String> resources) {
}
}

View File

@@ -0,0 +1,49 @@
spring:
application:
name: smart-factory-approval-agent
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:54330/smart_factory_agent}
username: ${SPRING_DATASOURCE_USERNAME:smart_factory}
password: ${SPRING_DATASOURCE_PASSWORD:smart_factory}
hikari:
maximum-pool-size: 12
minimum-idle: 2
flyway:
enabled: true
validate-on-migrate: true
servlet:
multipart:
max-file-size: 150MB
max-request-size: 160MB
threads:
virtual:
enabled: true
jackson:
default-property-inclusion: non_null
server:
port: ${SERVER_PORT:8080}
servlet:
session:
cookie:
http-only: true
same-site: lax
secure: ${SESSION_COOKIE_SECURE:false}
app:
data-root: ${APP_DATA_ROOT:file:../data}
deepseek-key-file: ${DEEPSEEK_KEY_FILE:./deepseek_key.txt}
dashscope-key-file: ${DASHSCOPE_KEY_FILE:./dashscope_key.txt}
master-key: ${APP_MASTER_KEY:smart-factory-local-master-key}
admin-username: ${APP_ADMIN_USERNAME:admin}
admin-password: ${APP_ADMIN_PASSWORD:admin123}
model-base-url: ${APP_MODEL_BASE_URL:https://api.deepseek.com}
model-id: ${APP_MODEL_ID:deepseek-v4-flash}
model-context-window: ${APP_MODEL_CONTEXT_WINDOW:131072}
sandbox-image: ${APP_SANDBOX_IMAGE:smart-factory-agent-runtime:0.1.0}
sandbox-network: ${APP_SANDBOX_NETWORK:bridge}
run-timeout: ${APP_RUN_TIMEOUT:60m}
logging:
pattern:
level: "%5p [trace:%X{traceId:-}]"

View File

@@ -0,0 +1,246 @@
CREATE SCHEMA app;
CREATE SCHEMA agentscope;
CREATE TABLE app.app_user (
id UUID PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE app.model_config (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
provider VARCHAR(32) NOT NULL,
base_url VARCHAR(500) NOT NULL,
model_id VARCHAR(255) NOT NULL,
api_key_ciphertext BYTEA,
api_key_hint VARCHAR(16),
key_version SMALLINT,
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
capabilities_json JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID REFERENCES app.app_user(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_model_provider CHECK (provider IN ('DASHSCOPE', 'OPENAI_COMPATIBLE')),
CONSTRAINT ck_model_key_pair CHECK (
(api_key_ciphertext IS NULL AND key_version IS NULL)
OR (api_key_ciphertext IS NOT NULL AND key_version IS NOT NULL)
),
CONSTRAINT ck_model_config_json CHECK (jsonb_typeof(config_json) = 'object'),
CONSTRAINT ck_model_capabilities_json CHECK (jsonb_typeof(capabilities_json) = 'object')
);
CREATE UNIQUE INDEX uk_model_config_default ON app.model_config (is_default) WHERE is_default;
CREATE TABLE app.model_assignment (
role VARCHAR(32) PRIMARY KEY,
model_config_id UUID NOT NULL REFERENCES app.model_config(id),
assigned_by UUID REFERENCES app.app_user(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_model_assignment_role CHECK (role IN ('ORCHESTRATION', 'WRITING', 'REVIEW'))
);
CREATE INDEX ix_model_assignment_model ON app.model_assignment (model_config_id);
CREATE TABLE app.project (
id UUID PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
project_name VARCHAR(255) NOT NULL,
agui_thread_id VARCHAR(255) NOT NULL UNIQUE,
application_level VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'MATERIAL_CHECK',
created_by UUID NOT NULL REFERENCES app.app_user(id),
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_project_application_level CHECK (application_level IN ('ADVANCED', 'EXCELLENT')),
CONSTRAINT ck_project_status CHECK (
status IN ('MATERIAL_CHECK', 'PLANNING', 'WRITING', 'DELIVERED', 'FAILED', 'ARCHIVED')
),
CONSTRAINT ck_project_version CHECK (version >= 0)
);
CREATE INDEX ix_project_updated_at ON app.project (updated_at DESC);
CREATE TABLE app.project_file (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
original_name VARCHAR(500) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
extension VARCHAR(32) NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'READY',
uploaded_by UUID NOT NULL REFERENCES app.app_user(id),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_project_file_path UNIQUE (project_id, relative_path),
CONSTRAINT ck_project_file_size CHECK (size_bytes >= 0),
CONSTRAINT ck_project_file_status CHECK (status IN ('UPLOADING', 'READY', 'FAILED', 'DELETED')),
CONSTRAINT ck_project_file_relative_path CHECK (
relative_path <> ''
AND left(relative_path, 1) <> '/'
AND relative_path !~ '(^|/)\.\.(/|$)'
)
);
CREATE INDEX ix_project_file_project ON app.project_file (project_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE TABLE app.project_plan (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
plan_version INTEGER NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT',
plan_json JSONB NOT NULL,
created_by UUID NOT NULL REFERENCES app.app_user(id),
confirmed_by UUID REFERENCES app.app_user(id),
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_project_plan_version UNIQUE (project_id, plan_version),
CONSTRAINT ck_project_plan_version CHECK (plan_version > 0),
CONSTRAINT ck_project_plan_status CHECK (status IN ('DRAFT', 'CONFIRMED', 'SUPERSEDED')),
CONSTRAINT ck_project_plan_json CHECK (jsonb_typeof(plan_json) = 'object'),
CONSTRAINT ck_project_plan_confirmation CHECK (
(status = 'CONFIRMED' AND confirmed_by IS NOT NULL AND confirmed_at IS NOT NULL)
OR (status <> 'CONFIRMED')
)
);
CREATE UNIQUE INDEX uk_project_plan_confirmed ON app.project_plan (project_id)
WHERE status = 'CONFIRMED';
CREATE TABLE app.agent_run (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
parent_run_id UUID,
model_config_id UUID REFERENCES app.model_config(id),
trigger_type VARCHAR(24) NOT NULL,
status VARCHAR(24) NOT NULL,
pending_interrupt JSONB,
trace_id VARCHAR(100) NOT NULL,
error_code VARCHAR(100),
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_agent_run_project UNIQUE (id, project_id),
CONSTRAINT fk_agent_run_parent_project
FOREIGN KEY (parent_run_id, project_id) REFERENCES app.agent_run(id, project_id),
CONSTRAINT ck_agent_run_trigger CHECK (trigger_type IN ('INITIAL', 'RESUME', 'RETRY')),
CONSTRAINT ck_agent_run_status CHECK (
status IN ('RUNNING', 'WAITING_INPUT', 'COMPLETED', 'FAILED', 'CANCELLED', 'INTERRUPTED')
),
CONSTRAINT ck_agent_run_pending_json CHECK (
pending_interrupt IS NULL OR jsonb_typeof(pending_interrupt) = 'object'
),
CONSTRAINT ck_agent_run_pending_status CHECK (
(status = 'WAITING_INPUT' AND pending_interrupt IS NOT NULL)
OR (status <> 'WAITING_INPUT' AND pending_interrupt IS NULL)
),
CONSTRAINT ck_agent_run_end CHECK (
(status = 'RUNNING' AND ended_at IS NULL)
OR (status IN ('WAITING_INPUT', 'COMPLETED', 'FAILED', 'CANCELLED', 'INTERRUPTED')
AND ended_at IS NOT NULL)
)
);
CREATE INDEX ix_agent_run_project ON app.agent_run (project_id, started_at DESC);
CREATE UNIQUE INDEX uk_agent_run_active ON app.agent_run (project_id)
WHERE status IN ('RUNNING', 'WAITING_INPUT');
CREATE TABLE app.agent_event (
id BIGSERIAL PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
run_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_id VARCHAR(255),
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_agent_event_run_project
FOREIGN KEY (run_id, project_id) REFERENCES app.agent_run(id, project_id),
CONSTRAINT ck_agent_event_payload CHECK (jsonb_typeof(payload) = 'object')
);
CREATE INDEX ix_agent_event_project ON app.agent_event (project_id, id);
CREATE INDEX ix_agent_event_run ON app.agent_event (run_id, id);
CREATE TABLE app.artifact (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
run_id UUID,
kind VARCHAR(32) NOT NULL,
name VARCHAR(500) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
published_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_artifact_path UNIQUE (project_id, relative_path),
CONSTRAINT fk_artifact_run_project
FOREIGN KEY (run_id, project_id) REFERENCES app.agent_run(id, project_id),
CONSTRAINT ck_artifact_kind CHECK (kind IN ('DOCX', 'PLANNING_REPORT', 'OTHER')),
CONSTRAINT ck_artifact_size CHECK (size_bytes > 0),
CONSTRAINT ck_artifact_metadata CHECK (jsonb_typeof(metadata_json) = 'object'),
CONSTRAINT ck_artifact_relative_path CHECK (
relative_path <> ''
AND left(relative_path, 1) <> '/'
AND relative_path !~ '(^|/)\.\.(/|$)'
)
);
CREATE INDEX ix_artifact_project ON app.artifact (project_id, published_at DESC);
CREATE TABLE agentscope.agentscope_skills (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
skill_content TEXT NOT NULL,
source VARCHAR(255) NOT NULL,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE agentscope.agentscope_skill_resources (
id BIGINT NOT NULL REFERENCES agentscope.agentscope_skills(id) ON DELETE CASCADE,
resource_path VARCHAR(500) NOT NULL,
resource_content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, resource_path)
);
CREATE TABLE app.skill_config (
skill_name VARCHAR(255) PRIMARY KEY
REFERENCES agentscope.agentscope_skills(name) ON DELETE CASCADE,
version VARCHAR(64),
source_type VARCHAR(16) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
read_only BOOLEAN NOT NULL DEFAULT TRUE,
checksum CHAR(64) NOT NULL,
validation_status VARCHAR(16) NOT NULL DEFAULT 'VALID',
validation_message TEXT,
imported_by UUID REFERENCES app.app_user(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_skill_source_type CHECK (source_type IN ('BUILTIN', 'IMPORTED')),
CONSTRAINT ck_skill_validation_status CHECK (validation_status IN ('VALID', 'INVALID')),
CONSTRAINT ck_skill_read_only CHECK (source_type <> 'BUILTIN' OR read_only)
);
CREATE INDEX ix_skill_config_enabled ON app.skill_config (enabled, skill_name) WHERE enabled;

View File

@@ -0,0 +1,39 @@
# 角色
你是智能工厂申报材料 Agent。你负责使用企业材料、百炼知识库、已启用 Skills 和项目工作区,形成建设规划、申报书审阅稿和评审结果。
# 语言
默认使用简体中文进行用户可见回复、执行说明、事实台账、建设规划和申报书编写。仅代码、命令、文件路径、标准原文、产品型号及无法准确翻译的专有名词保留原语言;用户明确要求其他语言时才切换。
# 事实与规划边界
- C企业材料或用户确认的企业事实。材料已经覆盖的字段必须忠实使用不得改写、美化或用案例替换。
- E企业公开资料必须保留来源并标记待企业确认。
- R政策、标准、行业方法和同行案例只用于支撑规划。
- P基于 R 形成的未来规划、建议目标和测算假设。规划确认后成为全书统一基线。
- U缺失、冲突或无法核实的企业现状写为待企业确认并进入 DOCX 原生批注。
- C/E/R/P/U 仅用于内部事实台账,不得出现在面向用户的 DOCX统一转换为“已确认 / 待核实 / 待确认”等可读口径。
材料未覆盖的建设内容,应主动形成具体、完整、可执行的 P。自由生成仅限未来规划不得编造企业当前设备、系统、营收、能耗、认证和既有成效。
未知企业现状必须使用“需确认是否……”“待企业提供……”等非断言句式。严禁先写成已发生、已具备或已承诺的肯定事实,再在句尾附“待确认”;真实性承诺也只能写为待签署或待提供。
# 自主执行
1. 先递归查看 `inputs/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。
2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。
3. `document_view` 是按需视觉补充工具,不是默认步骤。仅当文档 Skill 提取结果明显不足、页面为扫描件,或 PDF/PPT/工作表的图示、布局、截图对判断重要时,才使用自身视觉能力查看实际页面。由你决定页码、幻灯片、工作表与范围;建议每次最多渲染 5 张,可分批调用。大型工作表应主动拆分 range 查看,无需模拟滚动。
4. 判断输入与事实充分程度,主动检索知识库并选择必要业务 Skill。
5. 先读企业材料,再用知识库补充政策、标准、行业方法和规划依据。缺少企业现状时保持未知,不得让知识库或同行案例冒充企业事实。
6. 将事实写入 work/facts将规划锚点写入 work/plans将引用写入 references。
材料检验阶段生成 `work/facts/material-check.json`;规划阶段生成 `work/plans/proposed-plan.json`
两者必须来自当前企业材料与本次知识库分析,不得套用固定企业方案。
阶段任务指定的结构化文件是必需产物。获得最小事实后应先写入合法初稿,再随分析持续更新;不得把必需产物推迟到全部可选读取和视觉检查之后。
7. 规划确认后保持名称、架构、场景、KPI、投资、周期和术语前后一致。
8. 常规工作区读写、搜索和 Shell 可自主执行;所有操作限定在 Docker 工作区内。不得探测宿主机、读取凭证、修改系统配置或访问与任务无关的网络服务;知识库访问只通过已启用的 RAG Skill。
9. 工具返回失败、非零退出码、参数错误或文件冲突时,将错误结果视为可诊断观察;分析原因,修正参数、路径或前置条件后重试,也可选择替代工具。单个工具失败不得直接结束 Run。只有安全策略拒绝、用户停止、模型重连耗尽或确认无可恢复路径时才终止且不得返回假成功。
10. 页面输出只保留业务结论、待确认事项和必要依据。不要汇报后台 JSON、内部相对或绝对路径、编码或 Markdown 格式、命令、Skill 名称、工具调用、校验过程,以及“已写入”“已保存到某文件”等内部执行细节;这些操作由执行信息流单独展示。
# 完成条件
只有规划已确认、事实与规划口径一致、未知企业事实已转为批注、DOCX 通过打开与结构校验后,才可发布产物。申报书正文目标为 1 万至 2 万汉字应完整展开建设背景、现状与差距、总体架构、建设场景、数据与系统集成、实施路径、投资与效益、保障机制等内容。关键未知企业事实允许待确认能够由标准、知识库、Skill 和已确认规划形成的未来场景、技术路径、阶段任务与建议指标必须充分写实,不得用批注或空泛表述代替正文。不得把 Markdown 表格分隔行写入 Word 表格。中文字体须使用英文族名 `SimSun`(正文)与 `SimHei`(标题),同一文本运行的 ascii、hAnsi 与 eastAsia 均须使用对应字体,避免跨平台渲染为方框。当前不要求生成目录,不要创建仅含 TOC 域且需要办公软件手动更新的空目录;须检查表格换行、页码与批注锚点。每个 Word 原生批注 ID 只能锚定一处,正文中对应的 commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一待确认问题若在多处出现,必须复制批注正文并为每处使用新的唯一 ID。

View File

@@ -0,0 +1,9 @@
请将当前会话压缩为可继续执行的结构化工作记忆。必须保留:
1. 企业名称、申报等级,以及 C已确认事实、E外部依据、R规划建议、P待实施、U待确认边界。
2. 已确认并冻结的建设规划包括建设方向、场景、KPI、投资区间、建设周期和版本。
3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。
4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。
5. 当前任务进度、失败原因、尚未完成的动作和最安全的下一步。
不得把知识库内容改写为企业事实,不得把规划建议改写为已建成现状。省略寒暄、重复过程和可从工作区重新读取的大段工具输出。

View File

@@ -0,0 +1,238 @@
package cn.alphaline.smartfactory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import cn.alphaline.smartfactory.agent.AgentEventService;
import cn.alphaline.smartfactory.artifact.ArtifactService;
import cn.alphaline.smartfactory.artifact.DocxValidator;
import cn.alphaline.smartfactory.project.ProjectService;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.postgresql.ds.PGSimpleDataSource;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* 验证 PostgreSQL 17 全量迁移及事件游标回放。
*/
@Testcontainers
class DatabaseAndEventIntegrationTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
@TempDir
private Path temporaryDirectory;
/**
* 在干净 PostgreSQL 17 实例执行并校验全部 Flyway 迁移。
*/
@BeforeAll
static void migrate() {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.load();
flyway.migrate();
flyway.validate();
assertThat(flyway.migrate().migrationsExecuted).isZero();
}
/**
* 验证核心表、索引和约束已建立。
*
* @throws Exception 数据库访问失败时抛出
*/
@Test
void shouldCreateCoreSchemaOnPostgres17() throws Exception {
try (var connection = DriverManager.getConnection(
POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
var statement = connection.createStatement();
var result = statement.executeQuery("""
SELECT count(*) FROM information_schema.tables
WHERE table_schema IN ('app', 'agentscope')
""")) {
assertThat(result.next()).isTrue();
assertThat(result.getInt(1)).isEqualTo(12);
}
}
/**
* 验证事件按项目全局 ID 增量回放且不重复。
*/
@Test
void shouldReplayEventsAfterCursorInOrder() {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
.param("id", userId).param("name", "u-" + userId).update();
jdbc.sql("""
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
""").param("id", modelId).param("name", "m-" + modelId).update();
jdbc.sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.agent_run(id, project_id, model_config_id, trigger_type, status, trace_id)
VALUES (:id, :projectId, :modelId, 'INITIAL', 'RUNNING', :trace)
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
.param("trace", UUID.randomUUID().toString()).update();
AgentEventService service = new AgentEventService(jdbc, new ObjectMapper());
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id();
long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id();
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
assertThat(service.listAfter(projectId, first, 100))
.extracting(AgentEventService.EventView::id)
.containsExactly(second, third);
var next = service.streamAfter(projectId, third)
.filter(event -> !"HEARTBEAT".equals(event.type()))
.next()
.toFuture();
long pushed = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "推送")).id();
assertThat(next.orTimeout(2, TimeUnit.SECONDS).join().id()).isEqualTo(pushed);
}
/**
* 验证重试生成同一路径产物时更新登记信息,避免唯一约束导致成功 Run 被标记失败。
*
* @throws Exception 临时文件写入失败时抛出
*/
@Test
void shouldReplaceArtifactMetadataForSameProjectPath() throws Exception {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
UUID firstRunId = UUID.randomUUID();
UUID secondRunId = UUID.randomUUID();
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
.param("id", userId).param("name", "u-" + userId).update();
jdbc.sql("""
INSERT INTO app.model_config(id, name, provider, base_url, model_id)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model')
""").param("id", modelId).param("name", "m-" + modelId).update();
jdbc.sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
for (UUID runId : List.of(firstRunId, secondRunId)) {
jdbc.sql("""
INSERT INTO app.agent_run(
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, :modelId, 'RETRY', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
.param("trace", UUID.randomUUID().toString()).update();
}
Path document = temporaryDirectory.resolve("draft.docx");
Files.writeString(document, "first");
ProjectFileService files = mock(ProjectFileService.class);
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
ArtifactService artifacts = new ArtifactService(jdbc, files, new DocxValidator());
ObjectMapper mapper = new ObjectMapper();
ArtifactService.ArtifactView first = artifacts.publish(
projectId, firstRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
mapper.createObjectNode().put("version", 1));
Files.writeString(document, "second version");
ArtifactService.ArtifactView second = artifacts.publish(
projectId, secondRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
mapper.createObjectNode().put("version", 2));
assertThat(second.id()).isEqualTo(first.id());
assertThat(second.runId()).isEqualTo(secondRunId);
assertThat(second.sizeBytes()).isEqualTo(Files.size(document));
assertThat(artifacts.list(projectId)).hasSize(1);
}
/**
* 验证项目真删除会清除所有关联业务记录。
*/
@Test
void shouldDeleteProjectRecords() {
JdbcClient jdbc = jdbc();
UUID userId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
.param("id", userId).param("name", "u-" + userId).update();
jdbc.sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '待删除企业', '待删除项目', :thread, 'ADVANCED', :userId)
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.agent_run(id, project_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, 'INITIAL', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
""").param("id", runId).param("projectId", projectId)
.param("trace", UUID.randomUUID().toString()).update();
jdbc.sql("""
INSERT INTO app.agent_event(project_id, run_id, event_type, payload)
VALUES (:projectId, :runId, 'RUN_FINISHED', '{}'::jsonb)
""").param("projectId", projectId).param("runId", runId).update();
jdbc.sql("""
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
VALUES (:id, :projectId, 1, 'DRAFT', '{}'::jsonb, :userId)
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.project_file(
id, project_id, original_name, stored_name, relative_path, mime_type,
extension, size_bytes, sha256, uploaded_by)
VALUES (:id, :projectId, 'input.txt', 'input.txt', 'inputs/input.txt',
'text/plain', 'txt', 1, :sha, :userId)
""").param("id", UUID.randomUUID()).param("projectId", projectId)
.param("sha", "0".repeat(64)).param("userId", userId).update();
jdbc.sql("""
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type, size_bytes, sha256)
VALUES (:id, :projectId, :runId, 'OTHER', 'result.txt', 'artifacts/result.txt',
'text/plain', 1, :sha)
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
.param("sha", "0".repeat(64)).update();
ProjectService service = new ProjectService(jdbc, mock(cn.alphaline.smartfactory.auth.UserService.class), new ObjectMapper());
service.delete(projectId);
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
Long count = jdbc.sql("SELECT COUNT(*) FROM app." + table + " WHERE project_id = :projectId")
.param("projectId", projectId)
.query(Long.class)
.single();
assertThat(count).as(table).isZero();
}
assertThat(jdbc.sql("SELECT COUNT(*) FROM app.project WHERE id = :projectId")
.param("projectId", projectId)
.query(Long.class)
.single()).isZero();
}
private JdbcClient jdbc() {
PGSimpleDataSource source = new PGSimpleDataSource();
source.setURL(POSTGRES.getJdbcUrl());
source.setUser(POSTGRES.getUsername());
source.setPassword(POSTGRES.getPassword());
return JdbcClient.create(source);
}
}

View File

@@ -0,0 +1,35 @@
package cn.alphaline.smartfactory;
import static org.assertj.core.api.Assertions.assertThat;
import cn.alphaline.smartfactory.config.AppProperties;
import cn.alphaline.smartfactory.model.KeyCipher;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.Test;
/**
* 验证模型密钥保护。
*/
class KeyCipherAndShellTest {
/**
* 验证密钥可恢复且相同明文每次产生不同密文。
*/
@Test
void shouldEncryptModelKeyWithRandomIv() {
KeyCipher cipher = new KeyCipher(properties());
byte[] first = cipher.encrypt("local-test-key");
byte[] second = cipher.encrypt("local-test-key");
assertThat(first).isNotEqualTo(second);
assertThat(cipher.decrypt(first)).isEqualTo("local-test-key");
}
private AppProperties properties() {
return new AppProperties(
Path.of("data"), Path.of("deepseek"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123", "https://api.example.test", "model",
131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
}
}

View File

@@ -0,0 +1,27 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* 验证 Agent 事件持久化的精简规则。
*/
class AgentExecutionServiceTest {
/**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/
@Test
void shouldStripInlineImageDataFromPersistedToolResult() {
String content = """
document_view_result={"images":[{"path":"work/tmp/document-view/a/render-1.png"}]}
{"type":"image","source":{"media_type":"image/png","data":"very-large-base64"}}
""";
String result = AgentExecutionService.stripInlineImageData(content);
assertThat(result).contains("render-1.png");
assertThat(result).doesNotContain("very-large-base64");
}
}

View File

@@ -0,0 +1,81 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* 验证 Agent Run 的模型重连边界。
*/
class AgentRunServiceTest {
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/
@Test
void shouldRetryOnlyRecoverableModelFailures() {
assertThat(AgentExecutionService.MAX_MODEL_RECONNECTS).isEqualTo(5);
assertThat(AgentExecutionService.isRetryableModelFailure(
new RuntimeException(new HttpTransportException("disconnected")))).isTrue();
assertThat(AgentExecutionService.isRetryableModelFailure(
new HttpTransportException("unavailable", 503, ""))).isTrue();
assertThat(AgentExecutionService.isRetryableModelFailure(
new HttpTransportException("invalid request", 400, ""))).isFalse();
assertThat(AgentExecutionService.isRetryableModelFailure(
new IllegalArgumentException("invalid prompt"))).isFalse();
}
/**
* 验证上下文压缩只按模型窗口 90% Token 触发。
*/
@Test
void shouldCompactAtNinetyPercentTokensOnly() {
var config = AgentFactory.compactionFor(100_000, "summary");
assertThat(config.getTriggerTokens()).isEqualTo(90_000);
assertThat(config.getTriggerMessages()).isZero();
assertThat(config.getSummaryPrompt()).isEqualTo("summary");
}
/**
* 验证 Agent 调用 Skill 时使用无来源后缀的名称,并完整保留仓库信息。
*/
@Test
void shouldExposeCanonicalSkillIdWithoutLosingSkillInformation() {
AgentSkill source = new AgentSkill(
Map.of("name", "pdf", "description", "读取 PDF", "version", "1.0"),
"使用说明",
Map.of("references/guide.md", "参考内容"),
"imported",
Path.of("/skills/pdf"));
AgentSkill canonical = AgentFactory.canonicalSkill(source);
assertThat(canonical.getSkillId()).isEqualTo("pdf");
assertThat(canonical.getMetadata()).isEqualTo(source.getMetadata());
assertThat(canonical.getSkillContent()).isEqualTo(source.getSkillContent());
assertThat(canonical.getResources()).isEqualTo(source.getResources());
assertThat(canonical.getSource()).isEqualTo("imported");
assertThat(canonical.getOriginDir()).isEqualTo(source.getOriginDir());
}
/**
* 验证模型输出年份数组时可归一化为前端和确认接口使用的规划年数。
*/
@Test
void shouldNormalizePlanningYearArray() {
ObjectNode plan = new ObjectMapper().createObjectNode();
plan.putArray("planningYears").add("2026").add("2027");
AgentOutputService.normalizePlanningYears(plan);
assertThat(plan.path("planningYears").asInt()).isEqualTo(2);
assertThat(plan.path("planningPeriod").asText()).isEqualTo("2026-2027");
}
}

View File

@@ -0,0 +1,28 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.Toolkit;
import org.junit.jupiter.api.Test;
/**
* 验证文档视觉工具可以被 AgentScope 注册并暴露结构化参数。
*/
class DocumentViewToolTest {
/**
* 验证多视图参数能够进入模型工具定义。
*/
@Test
void shouldRegisterMultiViewSchema() {
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DocumentViewTool(new ObjectMapper()));
ToolSchema schema = toolkit.getToolSchemas().getFirst();
assertThat(schema.getName()).isEqualTo("document_view");
assertThat(schema.getParameters().toString()).contains("views", "path", "page", "sheet", "range");
}
}

View File

@@ -0,0 +1,66 @@
package cn.alphaline.smartfactory.agent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
/**
* 验证文件读取工具不会静默丢失后续内容。
*/
class PagedReadFileToolTest {
/**
* 沙箱返回分页元数据时,应将下一页位置明确告知 Agent。
*/
@Test
void shouldTellAgentHowToContinueReading() {
AbstractSandboxFilesystem filesystem = mock(AbstractSandboxFilesystem.class);
String content = Base64.getEncoder().encodeToString("第一行\n第二行".getBytes(StandardCharsets.UTF_8));
when(filesystem.execute(any(RuntimeContext.class), anyString(), isNull()))
.thenReturn(new ExecuteResponse(
"{\"ok\":true,\"truncated\":true,\"nextOffset\":2,\"returnedLines\":2,\"lineTooLong\":false}\n"
+ content,
0,
false));
PagedReadFileTool tool = new PagedReadFileTool(
filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper());
String result = tool.readFile(RuntimeContext.empty(), "/workspace/work/report.txt", 0, 2);
assertThat(result).contains("第一行\n第二行");
assertThat(result).contains("内容未读完");
assertThat(result).contains("offset=2");
}
/**
* 沙箱自身发生截断时,应明确提示重试,不能把残缺内容当作完整结果。
*/
@Test
void shouldExposeUnexpectedSandboxTruncation() {
AbstractSandboxFilesystem filesystem = mock(AbstractSandboxFilesystem.class);
when(filesystem.execute(any(RuntimeContext.class), anyString(), isNull()))
.thenReturn(new ExecuteResponse(
"{\"ok\":true,\"truncated\":true,\"nextOffset\":10,\"returnedLines\":10,\"lineTooLong\":false}\n",
0,
true));
PagedReadFileTool tool = new PagedReadFileTool(
filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper());
String result = tool.readFile(RuntimeContext.empty(), "work/report.txt", 0, 10);
assertThat(result).contains("内容未读完");
assertThat(result).contains("offset=10");
}
}

View File

@@ -0,0 +1,125 @@
package cn.alphaline.smartfactory.artifact;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import cn.alphaline.smartfactory.common.ApiException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* 验证 DOCX 发布边界会拒绝伪装文件和批注错锚。
*/
class DocxValidatorTest {
private final DocxValidator validator = new DocxValidator();
/**
* 验证最小有效 Word 文档可通过结构校验。
*
* @param directory 临时目录
* @throws IOException DOCX 写入失败时抛出
*/
@Test
void shouldValidateMinimalDocx(@TempDir Path directory) throws IOException {
Path docx = writeDocx(directory.resolve("valid.docx"), Map.of());
DocxValidator.ValidationResult result = validator.validate(docx);
assertThat(result.entryCount()).isEqualTo(3);
assertThat(result.commentCount()).isZero();
}
/**
* 验证批注锚点与批注定义不一致时拒绝发布。
*
* @param directory 临时目录
* @throws IOException DOCX 写入失败时抛出
*/
@Test
void shouldRejectBrokenCommentAnchor(@TempDir Path directory) throws IOException {
Map<String, String> extras = new LinkedHashMap<>();
extras.put("word/comments.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:comment w:id="9"><w:p><w:r><w:t>待确认</w:t></w:r></w:p></w:comment>
</w:comments>
""");
extras.put("word/_rels/document.xml.rels", """
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="comments" Target="comments.xml"/>
</Relationships>
""");
Path docx = writeDocx(directory.resolve("broken.docx"), extras);
assertThatThrownBy(() -> validator.validate(docx))
.isInstanceOf(ApiException.class)
.hasMessageContaining("批注");
}
/**
* 验证同一批注编号存在多个正文锚点时拒绝发布。
*
* @param directory 临时目录
* @throws IOException DOCX 写入失败时抛出
*/
@Test
void shouldRejectDuplicateCommentAnchor(@TempDir Path directory) throws IOException {
Map<String, String> extras = new LinkedHashMap<>();
extras.put("word/document.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p>
<w:commentRangeStart w:id="9"/><w:r><w:t>待确认</w:t></w:r><w:commentRangeEnd w:id="9"/>
<w:r><w:commentReference w:id="9"/></w:r><w:r><w:commentReference w:id="9"/></w:r>
</w:p></w:body>
</w:document>
""");
extras.put("word/comments.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:comment w:id="9"><w:p><w:r><w:t>待确认</w:t></w:r></w:p></w:comment>
</w:comments>
""");
extras.put("word/_rels/document.xml.rels", """
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="comments" Target="comments.xml"/>
</Relationships>
""");
assertThatThrownBy(() -> validator.validate(writeDocx(directory.resolve("duplicate.docx"), extras)))
.isInstanceOf(ApiException.class)
.hasMessageContaining("重复");
}
private Path writeDocx(Path path, Map<String, String> extras) throws IOException {
Map<String, String> entries = new LinkedHashMap<>();
entries.put("[Content_Types].xml", "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"/>");
entries.put("_rels/.rels", "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>");
entries.put("word/document.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>智能工厂申报书</w:t></w:r></w:p></w:body>
</w:document>
""");
entries.putAll(extras);
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(path))) {
for (Map.Entry<String, String> entry : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(entry.getKey()));
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return path;
}
}

View File

@@ -0,0 +1,43 @@
package cn.alphaline.smartfactory.auth;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.alphaline.smartfactory.common.GlobalExceptionHandler;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 验证登录边界返回稳定的认证错误码。
*/
class AuthControllerTest {
/**
* 验证错误密码返回 401且不会落入 500 兜底。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldReturnUnauthorizedForWrongPassword() throws Exception {
AuthenticationManager manager = mock(AuthenticationManager.class);
when(manager.authenticate(any())).thenThrow(new BadCredentialsException("bad credentials"));
MockMvc mvc = MockMvcBuilders.standaloneSetup(new AuthController(manager))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"admin\",\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
}
}

View File

@@ -0,0 +1,24 @@
package cn.alphaline.smartfactory.common;
import static org.assertj.core.api.Assertions.assertThatCode;
import org.junit.jupiter.api.Test;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
/**
* 验证统一异常边界对流式连接终止的处理。
*/
class GlobalExceptionHandlerTest {
/**
* 客户端断开已提交的流式响应时不再尝试写入 JSON 错误体。
*/
@Test
void shouldIgnoreExpectedStreamDisconnect() {
GlobalExceptionHandler handler = new GlobalExceptionHandler();
assertThatCode(() -> handler.handleClientDisconnect(
new AsyncRequestNotUsableException("ServletOutputStream failed to flush")))
.doesNotThrowAnyException();
}
}

View File

@@ -0,0 +1,87 @@
package cn.alphaline.smartfactory.project;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.config.AppProperties;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 验证项目工作区文件操作。
*/
class ProjectFileServiceTest {
@TempDir
private Path temporaryDirectory;
/**
* 验证删除项目时会递归删除其受控工作区。
*
* @throws Exception 测试文件创建失败时抛出
*/
@Test
void shouldDeleteProjectWorkspace() throws Exception {
AppProperties properties = new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
ProjectFileService service = new ProjectFileService(
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
UUID projectId = UUID.randomUUID();
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
Files.createDirectories(file.getParent());
Files.writeString(file, "企业材料");
service.deleteWorkspace(projectId);
assertThat(service.projectRoot(projectId)).doesNotExist();
}
/**
* 验证上传路径保留文件夹与原文件名。
*/
@Test
void shouldPreserveUploadedFolderPath() {
ProjectFileService service = service();
assertThat(service.normalizeUploadPath("测试输入/场景一/设备清单.xlsx", "设备清单.xlsx"))
.isEqualTo("inputs/测试输入/场景一/设备清单.xlsx");
assertThat(service.normalizeUploadPath(null, "企业材料.pdf"))
.isEqualTo("inputs/企业材料.pdf");
}
/**
* 验证上传路径不能越出 inputs 工作区。
*/
@Test
void shouldRejectUnsafeFolderPath() {
ProjectFileService service = service();
assertThatThrownBy(() -> service.normalizeUploadPath("../企业材料.pdf", "企业材料.pdf"))
.isInstanceOf(cn.alphaline.smartfactory.common.ApiException.class);
assertThatThrownBy(() -> service.normalizeUploadPath("其他文件.pdf", "企业材料.pdf"))
.isInstanceOf(cn.alphaline.smartfactory.common.ApiException.class);
}
/**
* 创建使用临时数据目录的文件服务。
*
* @return 文件服务
*/
private ProjectFileService service() {
AppProperties properties = new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
return new ProjectFileService(
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
}
}

View File

@@ -0,0 +1,73 @@
package cn.alphaline.smartfactory.skill;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
/**
* 验证 Skill ZIP 的目录识别与无关文件过滤。
*/
class SkillArchiveTest {
@TempDir
private Path temporaryDirectory;
/**
* 验证 macOS 元数据与 Python 缓存不会阻止有效 Skill 导入。
*
* @throws Exception ZIP 构造或解压失败时抛出
*/
@Test
void shouldLocateSkillAndIgnoreGeneratedFiles() throws Exception {
Map<String, byte[]> files = new LinkedHashMap<>();
files.put("sample/SKILL.md", "---\nname: sample\ndescription: 示例 Skill\n---\n规则".getBytes(StandardCharsets.UTF_8));
files.put("sample/scripts/run.py", "print('ok')".getBytes(StandardCharsets.UTF_8));
files.put("__MACOSX/._sample", new byte[]{0, 1});
files.put("sample/.DS_Store", new byte[]{0, 1});
files.put("sample/scripts/__pycache__/run.pyc", new byte[]{0, 1});
MockMultipartFile archive = new MockMultipartFile(
"file", "sample.zip", "application/zip", zip(files));
Path extracted = temporaryDirectory.resolve("extracted");
Files.createDirectories(extracted);
SkillService.unzip(archive, extracted);
Path skillRoot = SkillService.locateSkillRoot(extracted);
SkillPackageReader.SkillPackage skillPackage = new SkillPackageReader().read(skillRoot, "imported");
assertThat(skillRoot.getFileName().toString()).isEqualTo("sample");
assertThat(skillPackage.skill().getName()).isEqualTo("sample");
assertThat(skillPackage.skill().getResourcePaths()).containsExactly("scripts/run.py");
assertThat(extracted.resolve("__MACOSX")).doesNotExist();
assertThat(skillRoot.resolve("scripts/__pycache__")).doesNotExist();
}
/**
* 构造测试 ZIP。
*
* @param files ZIP 内文件
* @return ZIP 字节
* @throws Exception ZIP 写入失败时抛出
*/
private byte[] zip(Map<String, byte[]> files) throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
for (Map.Entry<String, byte[]> file : files.entrySet()) {
zip.putNextEntry(new ZipEntry(file.getKey()));
zip.write(file.getValue());
zip.closeEntry();
}
}
return output.toByteArray();
}
}

13
web-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>智造申报 Agent</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3608
web-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web-ui/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "smart-factory-approval-agent-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"@ag-ui/client": "0.0.58",
"@element-plus/icons-vue": "^2.3.2",
"@lucide/vue": "^1.35.0",
"element-plus": "2.14.5",
"markstream-vue": "2.0.6",
"vue": "3.5.41",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^3.2.4",
"vue-tsc": "^3.2.2"
}
}

106
web-ui/src/App.vue Normal file
View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Box, Folder, MagicStick } from '@element-plus/icons-vue'
import { api, type Project } from './api'
const route = useRoute()
const router = useRouter()
const projects = ref<Project[]>([])
const createOpen = ref(false)
const companyName = ref('')
const level = ref<'ADVANCED' | 'EXCELLENT'>('ADVANCED')
const creating = ref(false)
const isLogin = computed(() => route.path === '/login')
const projectRoute = computed(() => route.path.startsWith('/projects'))
async function loadProjects() {
if (isLogin.value) return
try {
projects.value = await api<Project[]>('/api/projects')
if (route.path === '/projects' && projects.value.length) {
await router.replace(`/projects/${projects.value[0].id}`)
}
} catch {
// api() 已负责跳转登录。
}
}
async function createProject() {
if (!companyName.value.trim() || creating.value) return
creating.value = true
try {
const project = await api<Project>('/api/projects', {
method: 'POST',
body: JSON.stringify({ companyName: companyName.value.trim(), applicationLevel: level.value })
})
projects.value.unshift(project)
createOpen.value = false
companyName.value = ''
await router.push(`/projects/${project.id}`)
} finally {
creating.value = false
}
}
onMounted(loadProjects)
watch(isLogin, value => { if (!value) void loadProjects() })
</script>
<template>
<RouterView v-if="isLogin" />
<div v-else class="app-shell">
<nav class="rail" aria-label="主导航">
<div class="brand"><Folder /></div>
<RouterLink to="/projects" :class="{ active: projectRoute }" aria-label="项目">
<Folder /><span>项目</span>
</RouterLink>
<RouterLink to="/models" :class="{ active: route.path === '/models' }" aria-label="模型">
<Box /><span>模型</span>
</RouterLink>
<RouterLink to="/skills" :class="{ active: route.path === '/skills' }" aria-label="Skills">
<MagicStick /><span>Skills</span>
</RouterLink>
</nav>
<aside v-if="projectRoute" class="project-list">
<div class="aside-title">
<h2>项目</h2>
<button class="icon-button" aria-label="新建项目" @click="createOpen = true"></button>
</div>
<p class="aside-label">当前会话</p>
<RouterLink
v-for="project in projects"
:key="project.id"
:to="`/projects/${project.id}`"
class="project-item"
:class="{ selected: route.params.id === project.id }"
>
<span>{{ project.companyName }}</span>
<time>{{ new Date(project.updatedAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 15) }}</time>
</RouterLink>
<div v-if="!projects.length" class="aside-empty">暂无项目</div>
</aside>
<main class="main-view"><RouterView @projects-changed="loadProjects" /></main>
<el-dialog v-model="createOpen" title="新建项目" width="420px" align-center>
<el-form label-position="top" @submit.prevent="createProject">
<el-form-item label="企业名称" required>
<el-input v-model="companyName" autofocus placeholder="输入企业全称" @keyup.enter="createProject" />
</el-form-item>
<el-form-item label="申报等级">
<el-radio-group v-model="level">
<el-radio-button value="ADVANCED">先进级</el-radio-button>
<el-radio-button value="EXCELLENT">卓越级</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createOpen = false">取消</el-button>
<el-button type="primary" :loading="creating" :disabled="!companyName.trim()" @click="createProject">创建</el-button>
</template>
</el-dialog>
</div>
</template>

142
web-ui/src/api.ts Normal file
View File

@@ -0,0 +1,142 @@
export interface Project {
id: string
companyName: string
projectName: string
threadId: string
applicationLevel: 'ADVANCED' | 'EXCELLENT'
status: 'MATERIAL_CHECK' | 'PLANNING' | 'WRITING' | 'DELIVERED' | 'FAILED' | 'ARCHIVED'
createdAt: string
updatedAt: string
}
export interface ProjectFile {
id: string
name: string
relativePath: string
extension: string
sizeBytes: number
status: string
createdAt: string
}
export interface AgentEvent {
id: number
projectId: string
runId: string | null
type: string
payload: Record<string, unknown>
createdAt: string
}
export interface PlanView {
id: string
status: string
version: number
plan: Record<string, unknown>
}
export interface Artifact {
id: string
name: string
kind: string
sizeBytes: number
metadataJson: string
publishedAt: string
}
let csrf: { token: string; headerName: string } | null = null
async function ensureCsrf() {
if (!csrf) csrf = await raw('/api/auth/csrf')
return csrf!
}
async function raw(path: string, options: RequestInit = {}) {
const response = await fetch(path, { credentials: 'include', ...options })
if (response.status === 401 && path !== '/api/auth/login') {
location.assign('/login')
throw new Error('登录状态已失效')
}
if (!response.ok) {
const body = await response.json().catch(() => ({ message: `请求失败(${response.status}` }))
throw new Error(body.message || `请求失败(${response.status}`)
}
if (response.status === 204) return null
return response.json()
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const method = (options.method || 'GET').toUpperCase()
const headers = new Headers(options.headers)
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const token = await ensureCsrf()
headers.set(token.headerName, token.token)
}
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
return raw(path, { ...options, headers })
}
export async function login(username: string, password: string) {
csrf = null
return raw('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
}
export function streamEvents(
projectId: string,
after: number,
onEvents: (events: AgentEvent[]) => void,
onError: (error: Error) => void
) {
const controller = new AbortController()
let cursor = after
let stopped = false
const connect = async () => {
while (!stopped) {
try {
const response = await fetch(`/api/projects/${projectId}/events/stream?after=${cursor}`, {
credentials: 'include',
signal: controller.signal,
headers: { Accept: 'application/x-ndjson' }
})
if (response.status === 401) {
location.assign('/login')
return
}
if (!response.ok || !response.body) throw new Error(`事件流连接失败(${response.status}`)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (!stopped) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
const batch = lines
.filter(Boolean)
.map(line => JSON.parse(line) as AgentEvent)
.filter(event => event.type !== 'HEARTBEAT')
if (batch.length) {
cursor = batch.at(-1)!.id
onEvents(batch)
}
}
if (!stopped) await new Promise(resolve => setTimeout(resolve, 1200))
} catch (error) {
if (controller.signal.aborted) return
onError(error instanceof Error ? error : new Error('事件流异常'))
await new Promise(resolve => setTimeout(resolve, 1200))
}
}
}
void connect()
return () => {
stopped = true
controller.abort()
}
}

View File

@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import { ElImageViewer } from 'element-plus'
import { describe, expect, it, vi } from 'vitest'
import AgentTimeline from './AgentTimeline.vue'
describe('AgentTimeline', () => {
it('运行中但没有真实增量时不渲染占位消息', () => {
const wrapper = shallowMount(AgentTimeline, { props: { events: [], running: true, projectId: 'p' } })
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).not.toContain('Agent 正在继续执行')
expect(wrapper.text()).not.toContain('思考中')
})
it('终态不渲染暂停输出占位提示', () => {
const wrapper = shallowMount(AgentTimeline, { props: { events: [], running: false, projectId: 'p' } })
expect(wrapper.text()).not.toContain('Agent 已暂停输出')
expect(wrapper.text()).not.toContain('Agent 正在执行')
})
it('展示真实的模型重连和用户停止事件', () => {
const wrapper = shallowMount(AgentTimeline, { props: { running: false, projectId: 'p', events: [
{ id: 1, projectId: 'p', runId: 'r', type: 'MODEL_RETRY', payload: { attempt: 2, maxAttempts: 5 }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'tool-1', toolCallName: 'execute' }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 3, projectId: 'p', runId: 'r', type: 'RUN_FINISHED', payload: { outcome: 'CANCELLED' }, createdAt: '2026-08-24T10:00:01Z' },
{ id: 4, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'late-tool', toolCallName: 'write_file' }, createdAt: '2026-08-24T10:00:02Z' }
] } })
expect(wrapper.text()).toContain('模型连接中断正在重连2/5')
expect(wrapper.text()).toContain('已停止')
expect(wrapper.text()).toContain('Agent 已停止,当前上下文已保留')
expect(wrapper.text()).not.toContain('运行中')
})
it('完成后展示已调用的工具和 Skill 名称', () => {
const events = [
{ id: 1, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'a', toolCallName: 'read_file' }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'a', delta: '{"path":"inputs/report.md"}' }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', payload: { toolCallId: 'a' }, createdAt: '2026-08-24T10:00:01Z' },
{ id: 4, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'b', toolCallName: 'load_skill_through_path' }, createdAt: '2026-08-24T10:00:02Z' },
{ id: 5, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'b', delta: '{"skillId":"pdf_imported"}' }, createdAt: '2026-08-24T10:00:02Z' },
{ id: 6, projectId: 'p', runId: 'r', type: 'TOOL_CALL_END', payload: { toolCallId: 'b' }, createdAt: '2026-08-24T10:00:03Z' },
{ id: 7, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'c', toolCallName: 'write_file' }, createdAt: '2026-08-24T10:00:04Z' },
{ id: 8, projectId: 'p', runId: 'r', type: 'TOOL_CALL_ARGS', payload: { toolCallId: 'c', delta: '{"path":"work/material-check.json"}' }, createdAt: '2026-08-24T10:00:04Z' }
]
const wrapper = shallowMount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
expect(wrapper.text()).toContain('已读取 report.md 文件')
expect(wrapper.text()).toContain('正在编辑 material-check.json 文件')
expect(wrapper.text()).toContain('已调用 pdf Skill')
})
it('每个 Run 只展示一次身份并安全渲染 Markdown 正文', async () => {
const events = [
{ id: 1, projectId: 'p', runId: 'r1', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm1', delta: '## 分析\n\n**关键结论**' }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 2, projectId: 'p', runId: 'r1', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm2', delta: '- 第一项\n- 第二项' }, createdAt: '2026-08-24T10:00:01Z' },
{ id: 3, projectId: 'p', runId: 'r2', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm3', delta: '<script>alert(1)</script>' }, createdAt: '2026-08-24T10:00:02Z' }
]
const wrapper = mount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('.agent-markdown h2').exists()).toBe(true))
expect(wrapper.findAll('.agent-avatar')).toHaveLength(2)
expect(wrapper.findAll('.flow-meta')).toHaveLength(2)
expect(wrapper.find('.agent-markdown h2').text()).toBe('分析')
expect(wrapper.find('.agent-markdown strong').text()).toBe('关键结论')
expect(wrapper.findAll('.agent-markdown li')).toHaveLength(2)
expect(wrapper.find('script').exists()).toBe(false)
})
it('完整展示正文并在浮层画廊中查看全部视觉结果', async () => {
const body = '正文'.repeat(500)
const result = 'document_view_result={"images":[{"index":1,"path":"work/tmp/document-view/a/render-1.png","sourcePath":"inputs/a.pdf","label":"第 1 页"},{"index":2,"path":"work/tmp/document-view/a/render-2.png","sourcePath":"inputs/a.pdf","label":"第 2 页"}],"errors":[]}'
const events = [
{ id: 1, projectId: 'p', runId: 'r', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm', delta: body }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 2, projectId: 'p', runId: 'r', type: 'TOOL_CALL_START', payload: { toolCallId: 'v', toolCallName: 'document_view' }, createdAt: '2026-08-24T10:00:01Z' },
{ id: 3, projectId: 'p', runId: 'r', type: 'TOOL_CALL_RESULT', payload: { toolCallId: 'v', content: result }, createdAt: '2026-08-24T10:00:02Z' }
]
const wrapper = mount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
await flushPromises()
await vi.waitFor(() => expect(wrapper.find('.agent-markdown').exists()).toBe(true))
expect(wrapper.text()).toContain(body)
expect(wrapper.text()).not.toContain('展开完整输出')
expect(wrapper.text()).toContain('已查看图片 · 2 张')
expect(wrapper.findAll('.view-image-item')).toHaveLength(2)
expect(wrapper.find('a[target="_blank"]').exists()).toBe(false)
await wrapper.findAll<HTMLButtonElement>('.view-image-item')[1].trigger('click')
expect(wrapper.findComponent(ElImageViewer).props('initialIndex')).toBe(1)
expect(wrapper.findComponent(ElImageViewer).props('urlList')).toHaveLength(2)
})
it('模型正文结束后延迟展示真实的结果整理状态', async () => {
vi.useFakeTimers()
const events = [
{ id: 1, projectId: 'p', runId: 'r', type: 'TEXT_MESSAGE_CONTENT', payload: { messageId: 'm', delta: '规划完成' }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 2, projectId: 'p', runId: 'r', type: 'TEXT_MESSAGE_END', payload: { messageId: 'm' }, createdAt: '2026-08-24T10:00:01Z' }
]
const wrapper = shallowMount(AgentTimeline, { props: { events, running: true, projectId: 'p' } })
expect(wrapper.text()).not.toContain('正在整理结果')
vi.advanceTimersByTime(500)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('正在整理结果')
await wrapper.setProps({ events: [...events, {
id: 3, projectId: 'p', runId: 'r', type: 'ASK_REQUESTED', payload: { kind: 'planning' }, createdAt: '2026-08-24T10:00:02Z'
}] })
expect(wrapper.text()).not.toContain('正在整理结果')
wrapper.unmount()
vi.useRealTimers()
})
it('材料确认后在时间线保留只读摘要', () => {
const events = [
{ id: 1, projectId: 'p', runId: 'r', type: 'ASK_REQUESTED', payload: { kind: 'material_check' }, createdAt: '2026-08-24T10:00:00Z' },
{ id: 2, projectId: 'p', runId: 'r', type: 'ASK_RESPONDED', payload: { decisions: [
{ id: 'a', action: 'ASSUMPTION' },
{ id: 'b', action: 'PENDING_COMMENT' },
{ id: 'c', action: 'UPLOADED' }
] }, createdAt: '2026-08-24T10:00:01Z' }
]
const wrapper = shallowMount(AgentTimeline, { props: { events, running: false, projectId: 'p' } })
expect(wrapper.text()).toContain('已确认材料检验 · 1 项按规划假设继续 · 1 项待确认 · 1 项已补充')
})
})

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue'
import { ElImageViewer } from 'element-plus'
import { CircleCheck, Document, MagicStick } from '@element-plus/icons-vue'
import { Bot, Brain, Images, Wrench } from '@lucide/vue'
import type { AgentEvent } from '../api'
const MarkdownRender = defineAsyncComponent(() => import('markstream-vue'))
const props = defineProps<{ events: AgentEvent[]; running: boolean; projectId: string }>()
const visibleLimit = ref(180)
const previewIndex = ref<number | null>(null)
const showFinalizing = ref(false)
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' })
let finalizingTimer: number | undefined
type ActivityStatus = 'running' | 'done' | 'failed' | 'stopped'
type ViewImage = { index: number; path: string; sourcePath: string; label: string }
type FlowItem =
| { key: string; runId: string | null; kind: 'message'; text: string; time: string; showIdentity: boolean; final: boolean }
| { key: string; runId: string | null; kind: 'reasoning'; text: string; status: ActivityStatus; time: string }
| { key: string; runId: string | null; kind: 'tool'; name: string; args: string; detail: string; images: ViewImage[]; status: ActivityStatus; time: string }
| { key: string; kind: 'notice'; text: string; tone: 'success' | 'error' | 'info'; time: string }
const flow = computed<FlowItem[]>(() => {
const items: FlowItem[] = []
const messages = new Map<string, FlowItem & { kind: 'message' }>()
const reasoning = new Map<string, FlowItem & { kind: 'reasoning' }>()
const tools = new Map<string, FlowItem & { kind: 'tool' }>()
const asks = new Map<string | null, Record<string, any>>()
const terminalRuns = new Map<string | null, Exclude<ActivityStatus, 'running'>>()
for (const event of props.events) {
const payload = event.payload || {}
const eventKey = (id: unknown) => `${event.runId || 'none'}:${String(id)}`
if (event.type === 'TEXT_MESSAGE_START') {
const id = eventKey(payload.messageId || event.id)
const item: FlowItem & { kind: 'message' } = {
key: `m-${id}`, runId: event.runId, kind: 'message', text: '', time: formatTime(event.createdAt),
showIdentity: false, final: false
}
messages.set(id, item)
items.push(item)
} else if (event.type === 'TEXT_MESSAGE_CONTENT' || event.type === 'TEXT_MESSAGE_CHUNK') {
const id = eventKey(payload.messageId || 'current')
let item = messages.get(id)
if (!item) {
item = {
key: `m-${id}`, runId: event.runId, kind: 'message', text: '',
time: formatTime(event.createdAt), showIdentity: false, final: false
}
messages.set(id, item)
items.push(item)
}
item.text += String(payload.delta || payload.content || '')
} else if (event.type === 'TEXT_MESSAGE_END') {
const item = messages.get(eventKey(payload.messageId || 'current'))
if (item) item.final = true
} else if (event.type === 'REASONING_MESSAGE_START' || event.type === 'REASONING_START') {
const id = eventKey(payload.messageId || payload.reasoningId || event.id)
const item: FlowItem & { kind: 'reasoning' } = {
key: `r-${id}`, runId: event.runId, kind: 'reasoning', text: '', status: 'running', time: formatTime(event.createdAt)
}
reasoning.set(id, item)
items.push(item)
} else if (event.type === 'REASONING_MESSAGE_CONTENT' || event.type === 'REASONING_MESSAGE_CHUNK') {
const id = eventKey(payload.messageId || payload.reasoningId || event.id)
let item = reasoning.get(id)
if (!item) {
item = {
key: `r-${id}`, runId: event.runId, kind: 'reasoning', text: '', status: 'running', time: formatTime(event.createdAt)
}
reasoning.set(id, item)
items.push(item)
}
item.text += String(payload.delta || payload.content || '')
} else if (event.type === 'REASONING_MESSAGE_END' || event.type === 'REASONING_END') {
const item = reasoning.get(eventKey(payload.messageId || payload.reasoningId || event.id))
if (item) item.status = 'done'
} else if (event.type === 'TOOL_CALL_START') {
const id = eventKey(payload.toolCallId || event.id)
const item: FlowItem & { kind: 'tool' } = {
key: `t-${id}`, runId: event.runId, kind: 'tool',
name: String(payload.toolCallName || payload.name || '工具'), args: '', detail: '', images: [],
status: 'running', time: formatTime(event.createdAt)
}
tools.set(id, item)
items.push(item)
} else if (event.type === 'TOOL_CALL_ARGS' || event.type === 'TOOL_CALL_CHUNK') {
const item = tools.get(eventKey(payload.toolCallId || payload.id || ''))
if (item) item.args += String(payload.delta || payload.args || '')
} else if (event.type === 'TOOL_CALL_RESULT' || event.type === 'TOOL_CALL_END') {
const item = tools.get(eventKey(payload.toolCallId || payload.id || ''))
if (item) {
const result = String(payload.content || payload.result || '')
item.status = /(?:执行失败|(?:^|\n)"?(?:error:|failed\b|exit code:\s*[1-9]))/i.test(result.trim()) ? 'failed' : 'done'
if (result) {
item.detail = summarize(result, 360)
if (item.name === 'document_view') item.images = parseViewImages(result)
}
}
} else if (event.type === 'ASK_REQUESTED') {
asks.set(event.runId, payload)
} else if (event.type === 'ASK_RESPONDED') {
const ask = asks.get(event.runId)
items.push({
key: `ask-${event.id}`, kind: 'notice', text: askSummary(ask, payload),
tone: 'success', time: formatTime(event.createdAt)
})
} else if (event.type === 'RUN_ERROR') {
terminalRuns.set(event.runId, 'failed')
closeActive(event.runId, 'failed', reasoning, tools)
items.push({
key: `e-${event.id}`, kind: 'notice', text: '执行遇到问题,已保留现有进度',
tone: 'error', time: formatTime(event.createdAt)
})
} else if (event.type === 'MODEL_RETRY') {
items.push({
key: `retry-${event.id}`, kind: 'notice',
text: `模型连接中断,正在重连(${payload.attempt}/${payload.maxAttempts || 5}`,
tone: 'info', time: formatTime(event.createdAt)
})
} else if (event.type === 'RUN_FINISHED' && payload.outcome === 'CANCELLED') {
terminalRuns.set(event.runId, 'stopped')
closeActive(event.runId, 'stopped', reasoning, tools)
items.push({
key: `cancel-${event.id}`, kind: 'notice', text: 'Agent 已停止,当前上下文已保留',
tone: 'info', time: formatTime(event.createdAt)
})
} else if (event.type === 'ARTIFACT_PUBLISHED') {
items.push({
key: `a-${event.id}`, kind: 'notice', text: '申报书审阅稿已生成',
tone: 'success', time: formatTime(event.createdAt)
})
} else if (event.type === 'RUN_FINISHED') {
terminalRuns.set(event.runId, 'done')
closeActive(event.runId, 'done', reasoning, tools)
}
}
for (const [runId, status] of terminalRuns) closeActive(runId, status, reasoning, tools)
for (const item of messages.values()) if (terminalRuns.has(item.runId)) item.final = true
const filtered = items.filter(item => (item.kind !== 'message' && item.kind !== 'reasoning') || cleanText(item.text))
const identifiedRuns = new Set<string | null>()
for (const item of filtered) {
if (item.kind === 'message' && !identifiedRuns.has(item.runId)) {
item.showIdentity = true
identifiedRuns.add(item.runId)
}
}
return filtered
})
const visibleFlow = computed(() => flow.value.slice(-visibleLimit.value))
const hiddenCount = computed(() => Math.max(0, flow.value.length - visibleFlow.value.length))
const galleryImages = computed(() => flow.value.flatMap(item => item.kind === 'tool'
? item.images.map(image => ({ key: `${item.key}:${image.path}`, image, url: previewUrl(image.path) }))
: []))
const galleryUrls = computed(() => galleryImages.value.map(item => item.url))
watch(
() => [props.running, props.events.at(-1)?.id, props.events.at(-1)?.type] as const,
([running, , type]) => {
window.clearTimeout(finalizingTimer)
showFinalizing.value = false
if (running && type === 'TEXT_MESSAGE_END') {
finalizingTimer = window.setTimeout(() => { showFinalizing.value = true }, 500)
}
},
{ immediate: true }
)
onBeforeUnmount(() => window.clearTimeout(finalizingTimer))
function closeActive(
runId: string | null,
status: Exclude<ActivityStatus, 'running'>,
reasoning: Map<string, FlowItem & { kind: 'reasoning' }>,
tools: Map<string, FlowItem & { kind: 'tool' }>
) {
for (const item of reasoning.values()) if (item.runId === runId && item.status === 'running') item.status = status
for (const item of tools.values()) if (item.runId === runId && item.status === 'running') item.status = status
}
function formatTime(value: string) {
return timeFormatter.format(new Date(value))
}
function askSummary(ask: Record<string, any> | undefined, response: Record<string, any>) {
if (ask?.kind !== 'material_check') return '已确认建设规划'
const decisions = Array.isArray(response.decisions) ? response.decisions : []
const assumptions = decisions.filter((item: any) => item?.action === 'ASSUMPTION').length
const pending = decisions.filter((item: any) => item?.action === 'PENDING_COMMENT').length
const uploaded = decisions.filter((item: any) => item?.action === 'UPLOADED').length
const parts = ['已确认材料检验']
if (assumptions) parts.push(`${assumptions} 项按规划假设继续`)
if (pending) parts.push(`${pending} 项待确认`)
if (uploaded) parts.push(`${uploaded} 项已补充`)
return parts.join(' · ')
}
function summarize(value: unknown, maxLength = 160) {
const text = cleanText(typeof value === 'string' ? value : JSON.stringify(value))
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text
}
function cleanText(value: string) {
return redactText(value)
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/\*\*|__|`{1,3}/g, '')
.replace(/^\s*\|?\s*:?-{3,}.*$/gm, '')
.trim()
}
function redactText(value: string) {
return value
.replace(/\/Users\/[^\s"')]+/g, '工作区路径')
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, '内部任务')
.replace(/sk-[A-Za-z0-9_-]{8,}/g, '已隐藏凭证')
}
function parseArgs(item: FlowItem & { kind: 'tool' }) {
try { return JSON.parse(item.args) as Record<string, any> } catch { return {} }
}
function skillName(item: FlowItem & { kind: 'tool' }) {
return String(parseArgs(item).skillId || '').replace(/_(?:builtin|imported)$/, '') || 'Skill'
}
function baseToolName(item: FlowItem & { kind: 'tool' }) {
const labels: Record<string, string> = {
list_files: '查看文件', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件',
glob_files: '查找文件', grep_files: '检索文件', execute: '工作区',
memory_search: '项目记忆检索', memory_save: '项目记忆保存'
}
return labels[item.name] || item.name
}
function activityLabel(item: FlowItem & { kind: 'tool' }) {
if (item.name === 'document_view') {
const count = item.images.length || requestedViewCount(item)
if (item.status === 'running') return '正在查看图片'
if (item.status === 'stopped') return '已停止查看图片'
if (item.status === 'failed') return '查看图片失败'
return `已查看图片${count ? ` · ${count}` : ''}`
}
if (item.name === 'read_file') return fileActivityLabel(item, '读取')
if (item.name === 'write_file' || item.name === 'edit_file') return fileActivityLabel(item, '编辑')
const skill = isSkill(item)
const name = skill ? skillName(item) : baseToolName(item)
if (item.status === 'running') return skill ? `正在调用 ${name} Skill` : `正在调用${name}工具`
if (item.status === 'stopped') return skill ? `已停止调用 ${name} Skill` : `已停止调用${name}工具`
if (item.status === 'failed') return skill ? `${name} Skill 调用失败` : `${name}工具调用失败`
return skill ? `已调用 ${name} Skill` : `已调用${name}工具`
}
function fileActivityLabel(item: FlowItem & { kind: 'tool' }, action: '读取' | '编辑') {
const path = String(parseArgs(item).path || '').replace(/\\/g, '/')
const name = path.split('/').filter(Boolean).pop()
const target = name ? `${name} 文件` : '文件'
if (item.status === 'running') return `正在${action} ${target}`
if (item.status === 'stopped') return `已停止${action} ${target}`
if (item.status === 'failed') return `${action} ${target}失败`
return `${action} ${target}`
}
function isSkill(item: FlowItem & { kind: 'tool' }) {
return item.name === 'load_skill_through_path' || item.name.toLowerCase().includes('skill')
}
function requestedViewCount(item: FlowItem & { kind: 'tool' }) {
const views = parseArgs(item).views
return Array.isArray(views) ? views.length : 0
}
function parseViewImages(content: string): ViewImage[] {
const marker = 'document_view_result='
const start = content.indexOf(marker)
if (start < 0) return []
try {
const line = content.slice(start + marker.length).split('\n', 1)[0]
const images = JSON.parse(line).images
return Array.isArray(images) ? images : []
} catch { return [] }
}
function previewUrl(path: string) {
return `/api/projects/${props.projectId}/view-images?path=${encodeURIComponent(path)}`
}
function openPreview(itemKey: string, path: string) {
const index = galleryImages.value.findIndex(item => item.key === `${itemKey}:${path}`)
if (index >= 0) previewIndex.value = index
}
function reasoningLabel(item: FlowItem & { kind: 'reasoning' }) {
if (item.status === 'running') return '正在思考'
if (item.status === 'stopped') return '已停止思考'
if (item.status === 'failed') return '思考中断'
return '已思考'
}
</script>
<template>
<div class="timeline">
<span v-if="running" class="sr-only" aria-live="polite">Agent 正在执行</span>
<button v-if="hiddenCount" class="load-earlier" @click="visibleLimit += 180">查看更早记录{{ hiddenCount }}</button>
<article v-for="item in visibleFlow" :key="item.key" class="flow-row" :class="`flow-${item.kind}`">
<div v-if="item.kind === 'message' && item.showIdentity" class="agent-avatar" aria-label="Agent"><Bot /></div>
<div class="flow-content">
<div v-if="item.kind === 'message' && item.showIdentity" class="flow-meta"><strong>Agent</strong><time>{{ item.time }}</time></div>
<MarkdownRender
v-if="item.kind === 'message'"
class="agent-markdown"
mode="chat"
:content="redactText(item.text).trim()"
:final="item.final"
smooth-streaming="auto"
:fade="false"
html-policy="escape"
/>
<details v-else-if="item.kind === 'reasoning'" class="activity-row reasoning-row" :class="{ active: item.status === 'running', failed: item.status === 'failed' }">
<summary><Brain /><span>{{ reasoningLabel(item) }}</span></summary>
<p>{{ cleanText(item.text) }}</p>
</details>
<details
v-else-if="item.kind === 'tool'"
class="activity-row tool-row"
:class="{ active: item.status === 'running', failed: item.status === 'failed', visual: item.name === 'document_view' }"
>
<summary>
<Images v-if="item.name === 'document_view'" />
<MagicStick v-else-if="isSkill(item)" />
<Wrench v-else />
<span>{{ activityLabel(item) }}</span>
</summary>
<div v-if="item.images.length" class="view-image-strip">
<button
v-for="image in item.images"
:key="image.path"
type="button"
class="view-image-item"
:aria-label="`查看渲染图片 ${image.index}`"
@click="openPreview(item.key, image.path)"
>
<img :src="previewUrl(image.path)" :alt="`渲染图片 ${image.index}`" loading="lazy" />
<span>渲染图片 {{ image.index }} · {{ image.label }}</span>
</button>
</div>
<p v-else-if="item.detail">{{ item.detail }}</p>
</details>
<div v-else class="notice-row" :class="item.tone">
<CircleCheck v-if="item.tone === 'success'" /><Document v-else />
<span>{{ item.text }}</span>
</div>
</div>
</article>
<div v-if="showFinalizing" class="finalizing-row" aria-live="polite">
<span>正在整理结果</span>
</div>
<ElImageViewer
v-if="previewIndex !== null && galleryUrls.length"
:url-list="galleryUrls"
:initial-index="previewIndex"
:infinite="true"
:show-progress="true"
:close-on-press-escape="true"
:teleported="true"
@close="previewIndex = null"
/>
</div>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
interface MissingItem {
id: string
label: string
reason?: string
required?: boolean
}
interface MaterialAsk {
interruptId: string
report?: {
summary?: string
completeness?: number
missingItems?: MissingItem[]
}
}
const props = defineProps<{
ask: MaterialAsk
loading?: boolean
uploadFile: (file: File) => Promise<unknown>
}>()
const emit = defineEmits<{ confirm: [response: Record<string, unknown>] }>()
const form = reactive<{ decisions: Record<string, string>; note: string; uploading: string }>({
decisions: {}, note: '', uploading: ''
})
const uploadErrors = reactive<Record<string, string>>({})
const showAll = ref(false)
const items = computed(() => props.ask.report?.missingItems || [])
const visibleItems = computed(() => showAll.value ? items.value : items.value.slice(0, 5))
const summary = computed(() => {
const value = props.ask.report?.summary || '请确认材料缺口的处理方式'
return value.length > 150 ? `${value.slice(0, 150)}` : value
})
const storageKey = computed(() => `material-ask:${props.ask.interruptId}`)
watch(
() => props.ask.interruptId,
() => {
const saved = localStorage.getItem(storageKey.value)
const draft = saved ? JSON.parse(saved) as { decisions?: Record<string, string>; note?: string } : {}
form.decisions = Object.fromEntries(items.value.map(item => [item.id, draft.decisions?.[item.id] || 'PENDING_COMMENT']))
form.note = draft.note || ''
showAll.value = false
},
{ immediate: true }
)
watch(
() => ({ decisions: form.decisions, note: form.note }),
value => localStorage.setItem(storageKey.value, JSON.stringify(value)),
{ deep: true }
)
async function upload(item: MissingItem, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
form.uploading = item.id
uploadErrors[item.id] = ''
try {
await props.uploadFile(file)
form.decisions[item.id] = 'UPLOADED'
} catch {
uploadErrors[item.id] = '上传失败,请重试'
} finally {
form.uploading = ''
input.value = ''
}
}
function confirm() {
emit('confirm', {
interruptId: props.ask.interruptId,
decisions: items.value.map(item => ({ id: item.id, action: form.decisions[item.id] })),
note: form.note
})
}
</script>
<template>
<section class="ask-card material-ask" aria-labelledby="material-title">
<h3 id="material-title">确认材料检验</h3>
<p>{{ summary }}</p>
<div class="material-items">
<div v-for="item in visibleItems" :key="item.id" class="material-item">
<div><strong>{{ item.label }}</strong><small v-if="item.reason">{{ item.reason }}</small><small v-if="uploadErrors[item.id]" class="field-error">{{ uploadErrors[item.id] }}</small></div>
<el-select v-model="form.decisions[item.id]" :aria-label="`${item.label}处理方式`">
<el-option label="在审阅稿中待确认" value="PENDING_COMMENT" />
<el-option label="暂无,按规划假设继续" value="ASSUMPTION" />
<el-option label="已补充上传" value="UPLOADED" disabled />
</el-select>
<label class="material-upload" :class="{ busy: form.uploading === item.id }">
{{ form.uploading === item.id ? '上传中' : '上传' }}
<input type="file" :disabled="Boolean(form.uploading)" @change="upload(item, $event)" />
</label>
</div>
<p v-if="!items.length" class="material-clear">现有材料可进入规划</p>
<button v-if="items.length > 5" class="material-more" @click="showAll = !showAll">
{{ showAll ? '收起' : `展开其他 ${items.length - 5} ` }}
</button>
</div>
<label class="plan-note">
<span>补充说明选填</span>
<el-input v-model="form.note" type="textarea" :rows="2" maxlength="300" />
</label>
<el-button type="primary" :loading="loading" :disabled="Boolean(form.uploading)" @click="confirm">确认并生成规划</el-button>
</section>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
const props = defineProps<{ planId: string; plan: Record<string, unknown>; loading?: boolean }>()
const emit = defineEmits<{ confirm: [plan: Record<string, unknown>] }>()
const form = reactive({
coreDirection: '',
collaborationDirection: '',
factoryName: '',
planningYears: 3,
investmentRange: '',
scenarioCount: 1,
aiScenarioCount: 0,
note: ''
})
watch(
() => [props.planId, props.plan] as const,
([planId, plan]) => {
const stored = localStorage.getItem(`plan-draft:${planId}`)
const draft = stored ? JSON.parse(stored) as Partial<typeof form> : {}
Object.assign(form, {
coreDirection: String(plan.coreDirection || ''),
collaborationDirection: String(plan.collaborationDirection || ''),
factoryName: String(plan.factoryName || ''),
planningYears: Number(plan.planningYears || 3),
investmentRange: String(plan.investmentRange || ''),
scenarioCount: Number(plan.scenarioCount || (Array.isArray(plan.scenarios) ? plan.scenarios.length : 1)),
aiScenarioCount: Number(plan.aiScenarioCount || 0),
note: String(plan.note || '')
}, draft)
},
{ immediate: true }
)
watch(form, value => localStorage.setItem(`plan-draft:${props.planId}`, JSON.stringify(value)), { deep: true })
const summary = computed(() => {
return `${form.planningYears} 年 · ${form.investmentRange} · ${form.scenarioCount} 个场景 · ${form.aiScenarioCount} 个 AI 场景`
})
const valid = computed(() => Boolean(
form.coreDirection.trim() && form.collaborationDirection.trim() && form.factoryName.trim()
&& form.investmentRange.trim() && form.planningYears > 0 && form.scenarioCount > 0
&& form.aiScenarioCount >= 0 && form.aiScenarioCount <= form.scenarioCount
))
function confirm() {
if (!valid.value) return
emit('confirm', { ...props.plan, ...form })
}
function restore() {
localStorage.removeItem(`plan-draft:${props.planId}`)
Object.assign(form, {
coreDirection: String(props.plan.coreDirection || ''),
collaborationDirection: String(props.plan.collaborationDirection || ''),
factoryName: String(props.plan.factoryName || ''),
planningYears: Number(props.plan.planningYears || 3),
investmentRange: String(props.plan.investmentRange || ''),
scenarioCount: Number(props.plan.scenarioCount || (Array.isArray(props.plan.scenarios) ? props.plan.scenarios.length : 1)),
aiScenarioCount: Number(props.plan.aiScenarioCount || 0),
note: String(props.plan.note || '')
})
}
</script>
<template>
<section class="ask-card" aria-labelledby="plan-title">
<h3 id="plan-title">确认建设规划</h3>
<p>确认后 Agent 将自主完成编写与评审</p>
<div class="plan-fields">
<label><span>核心建设方向</span><el-input v-model="form.coreDirection" /></label>
<label><span>协同建设方向</span><el-input v-model="form.collaborationDirection" /></label>
<label><span>智能工厂名称</span><el-input v-model="form.factoryName" /></label>
<label><span>规划周期</span><el-input-number v-model="form.planningYears" :min="1" :max="10" controls-position="right" /></label>
<label><span>投资范围</span><el-input v-model="form.investmentRange" /></label>
<label><span>重点场景</span><el-input-number v-model="form.scenarioCount" :min="1" :max="60" controls-position="right" /></label>
<label><span>AI 场景</span><el-input-number v-model="form.aiScenarioCount" :min="0" :max="form.scenarioCount" controls-position="right" /></label>
</div>
<div class="plan-summary">规划期 {{ summary }}</div>
<label class="plan-note">
<span>补充规划约束选填</span>
<el-input v-model="form.note" type="textarea" :rows="3" maxlength="300" show-word-limit />
</label>
<div class="ask-actions">
<el-button type="primary" :loading="loading" :disabled="!valid" @click="confirm">确认并开始编写</el-button>
<el-button @click="restore">恢复 Agent 建议</el-button>
</div>
</section>
</template>

57
web-ui/src/eventCache.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { AgentEvent } from './api'
const DB_NAME = 'smart-factory-agent'
const STORE_NAME = 'events'
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1)
request.onupgradeneeded = () => {
const store = request.result.createObjectStore(STORE_NAME, { keyPath: ['projectId', 'id'] })
store.createIndex('projectId', 'projectId')
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
export async function readCachedEvents(projectId: string): Promise<AgentEvent[]> {
const database = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, 'readonly')
const request = transaction.objectStore(STORE_NAME).index('projectId').getAll(projectId)
request.onsuccess = () => resolve((request.result as AgentEvent[]).sort((a, b) => a.id - b.id))
request.onerror = () => reject(request.error)
transaction.oncomplete = () => database.close()
})
}
export async function cacheEvents(events: AgentEvent[]) {
if (!events.length) return
const database = await openDatabase()
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
for (const event of events) store.put(event)
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
database.close()
}
export async function deleteCachedEvents(projectId: string) {
const database = await openDatabase()
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, 'readwrite')
const request = transaction.objectStore(STORE_NAME).index('projectId').openCursor(IDBKeyRange.only(projectId))
request.onsuccess = () => {
const cursor = request.result
if (!cursor) return
cursor.delete()
cursor.continue()
}
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
database.close()
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import type { AgentEvent } from './api'
import { appendUniqueEvents } from './eventUtils'
const event = (id: number): AgentEvent => ({
id,
projectId: 'project',
runId: 'run',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: String(id) },
createdAt: '2026-08-24T00:00:00Z'
})
describe('appendUniqueEvents', () => {
it('保留顺序并忽略重放事件', () => {
expect(appendUniqueEvents([event(1), event(2)], [event(2), event(3)]).map(item => item.id))
.toEqual([1, 2, 3])
})
})

10
web-ui/src/eventUtils.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { AgentEvent } from './api'
/** 合并重连批次并按事件主键去重。 */
export function appendUniqueEvents(current: AgentEvent[], incoming: AgentEvent[]) {
if (!incoming.length) return current
const lastId = current.at(-1)?.id
if (lastId == null || incoming[0].id > lastId) return current.concat(incoming)
const ids = new Set(current.map(event => event.id))
return current.concat(incoming.filter(event => !ids.has(event.id)))
}

32
web-ui/src/main.ts Normal file
View File

@@ -0,0 +1,32 @@
import { createApp } from 'vue'
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElInputNumber,
ElOption,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSwitch,
ElTabPane,
ElTabs,
ElUpload
} from 'element-plus'
import 'element-plus/dist/index.css'
import 'markstream-vue/index.css'
import './styles.css'
import App from './App.vue'
import { router } from './router'
const app = createApp(App)
for (const component of [
ElButton, ElDialog, ElForm, ElFormItem, ElIcon, ElInput,
ElInputNumber, ElOption, ElRadioButton, ElRadioGroup, ElSelect,
ElSwitch, ElTabPane, ElTabs, ElUpload
]) app.component(component.name!, component)
app.use(router)
void router.isReady().then(() => app.mount('#app'))

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '../api'
const router = useRouter()
const username = ref('admin')
const password = ref('admin123')
const loading = ref(false)
const error = ref('')
async function submit() {
if (loading.value) return
loading.value = true
error.value = ''
try {
await login(username.value, password.value)
await router.replace('/projects')
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<form class="login-panel" @submit.prevent="submit">
<div class="brand large"></div>
<h1>智造申报 Agent</h1>
<el-input v-model="username" autocomplete="username" aria-label="用户名" placeholder="用户名" />
<el-input
v-model="password"
type="password"
show-password
autocomplete="current-password"
aria-label="密码"
placeholder="密码"
/>
<p v-if="error" class="form-error">{{ error }}</p>
<el-button native-type="submit" type="primary" :loading="loading" class="full-button">登录</el-button>
</form>
</main>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleCheck } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
interface ModelConfig {
id: string
name: string
provider: string
baseUrl: string
modelId: string
apiKeyHint: string
configJson: string
capabilitiesJson: string
enabled: boolean
defaultModel: boolean
}
const models = ref<ModelConfig[]>([])
const selectedId = ref('')
const saving = ref(false)
const testing = ref(false)
const tested = ref(false)
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
const selected = computed(() => models.value.find(model => model.id === selectedId.value))
async function load() {
models.value = await api<ModelConfig[]>('/api/models')
select(models.value.find(model => model.defaultModel)?.id || models.value[0]?.id || '')
}
function select(id: string) {
selectedId.value = id
const model = models.value.find(item => item.id === id)
if (!model) return
let capabilities: { contextWindow?: number } = {}
try { capabilities = JSON.parse(model.capabilitiesJson) } catch { capabilities = {} }
Object.assign(form, {
name: model.name,
baseUrl: model.baseUrl,
modelId: model.modelId,
apiKey: '',
contextWindow: capabilities.contextWindow || 131072
})
tested.value = false
}
async function save() {
saving.value = true
try {
await api(`/api/models/${selectedId.value}`, {
method: 'PUT',
body: JSON.stringify({
name: form.name,
baseUrl: form.baseUrl,
modelId: form.modelId,
apiKey: form.apiKey,
config: { timeoutSeconds: 120, reasoningEffort: 'high' },
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
})
})
ElMessage.success('已保存')
await load()
} finally {
saving.value = false
}
}
async function test() {
testing.value = true
tested.value = false
try {
await api(`/api/models/${selectedId.value}/test`, { method: 'POST' })
tested.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '连接失败')
} finally {
testing.value = false
}
}
async function setDefault() {
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' })
await load()
}
onMounted(load)
</script>
<template>
<section class="settings-page">
<header><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></header>
<div class="settings-grid">
<aside class="settings-list">
<h2>已配置模型</h2>
<button
v-for="model in models"
:key="model.id"
:class="{ selected: selectedId === model.id }"
@click="select(model.id)"
>
<strong>{{ model.name }}</strong>
<span>DeepSeek · {{ model.modelId }}</span>
<small><i></i>可用</small>
</button>
</aside>
<form v-if="selected" class="settings-form" @submit.prevent="save">
<div class="form-title"><h2>{{ selected.name }}</h2><el-button v-if="!selected.defaultModel" @click="setDefault">设为默认</el-button><span v-else class="tag blue">默认</span></div>
<label><span>服务商</span><el-input model-value="DeepSeek" disabled /></label>
<label><span>API 地址</span><el-input v-model="form.baseUrl" /></label>
<label><span>API Key</span><el-input v-model="form.apiKey" type="password" show-password :placeholder="selected.apiKeyHint" /></label>
<label><span>模型 ID</span><el-input v-model="form.modelId" /></label>
<label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label>
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
<div class="model-actions">
<el-button native-type="submit" type="primary" :loading="saving">保存配置</el-button>
<el-button :loading="testing" @click="test">测试连接</el-button>
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
</div>
</form>
</div>
</section>
</template>

View File

@@ -0,0 +1,426 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Document, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AgentTimeline from '../components/AgentTimeline.vue'
import MaterialAskCard from '../components/MaterialAskCard.vue'
import PlanCard from '../components/PlanCard.vue'
import { api, streamEvents, type AgentEvent, type Artifact, type PlanView, type Project, type ProjectFile } from '../api'
import { cacheEvents, deleteCachedEvents, readCachedEvents } from '../eventCache'
import { appendUniqueEvents } from '../eventUtils'
const route = useRoute()
const router = useRouter()
const emit = defineEmits<{ 'projects-changed': [] }>()
const project = ref<Project | null>(null)
const files = ref<ProjectFile[]>([])
const events = shallowRef<AgentEvent[]>([])
const plan = ref<PlanView | null>(null)
const pendingAsk = ref<Record<string, any> | null>(null)
const artifacts = ref<Artifact[]>([])
const runStatus = ref('')
const loading = ref(false)
const folderUploading = ref(false)
const controlLoading = ref(false)
const deleting = ref(false)
const historyLoading = ref(true)
const streamError = ref('')
const showBackToBottom = ref(false)
let stopStream: (() => void) | null = null
let loadVersion = 0
const folderInput = ref<HTMLInputElement | null>(null)
const projectId = computed(() => String(route.params.id || ''))
const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan.value?.status === 'DRAFT')
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
const running = computed(() => runStatus.value === 'RUNNING')
const interrupted = computed(() => runStatus.value === 'INTERRUPTED')
const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级')
const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
}[project.value?.status || 'MATERIAL_CHECK']))
async function load(id: string) {
const version = ++loadVersion
historyLoading.value = true
stopStream?.()
stopStream = null
project.value = null
events.value = []
try {
const cached = await readCachedEvents(id).catch(() => [])
const [loadedProject, loadedFiles, loadedArtifacts, loadedPlan, latest] = await Promise.all([
api<Project>(`/api/projects/${id}`),
api<ProjectFile[]>(`/api/projects/${id}/files`),
api<Artifact[]>(`/api/projects/${id}/artifacts`),
api<PlanView | null>(`/api/projects/${id}/plan`),
api<{ status: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`)
])
const loadedEvents = await fetchMissingEvents(id, cached)
if (version !== loadVersion || id !== projectId.value) return
project.value = loadedProject
files.value = loadedFiles
artifacts.value = loadedArtifacts
plan.value = loadedPlan
events.value = loadedEvents
runStatus.value = latest?.status || ''
pendingAsk.value = parseAsk(latest?.pendingInterrupt)
startStream(id, version)
} finally {
if (version === loadVersion) historyLoading.value = false
}
}
async function fetchMissingEvents(id: string, initial: AgentEvent[]) {
let result = initial
let cursor = result.at(-1)?.id || 0
const pending: AgentEvent[] = []
while (true) {
const batch = await api<AgentEvent[]>(`/api/projects/${id}/events?after=${cursor}`)
if (!batch.length) break
pending.push(...batch)
await cacheEvents(batch).catch(() => undefined)
cursor = batch.at(-1)!.id
if (batch.length < 1000) break
}
if (pending.length) result = appendUniqueEvents(result, pending)
return result
}
function mergeEvents(batch: AgentEvent[]) {
events.value = appendUniqueEvents(events.value, batch)
}
function applyEvents(batch: AgentEvent[], id = projectId.value, version = loadVersion) {
if (id !== projectId.value || version !== loadVersion) return
mergeEvents(batch)
let projectChanged = false
let planChanged = false
let artifactsChanged = false
for (const event of batch) {
if (event.type === 'RUN_STARTED') {
runStatus.value = 'RUNNING'
pendingAsk.value = null
}
if (event.type === 'ASK_REQUESTED') {
pendingAsk.value = event.payload
runStatus.value = 'WAITING_INPUT'
projectChanged = true
if (event.payload.kind === 'planning') planChanged = true
}
if (event.type === 'ASK_RESPONDED') pendingAsk.value = null
if (event.type === 'RUN_FINISHED') {
if (event.payload.outcome === 'CANCELLED') runStatus.value = 'INTERRUPTED'
else if (event.payload.outcome !== 'INTERRUPT') runStatus.value = 'COMPLETED'
if (event.payload.outcome !== 'INTERRUPT') projectChanged = true
}
if (event.type === 'RUN_ERROR') {
runStatus.value = 'FAILED'
pendingAsk.value = null
projectChanged = true
}
if (event.type === 'ARTIFACT_PUBLISHED') artifactsChanged = true
}
if (projectChanged) void refreshProject(id, version)
if (planChanged) void refreshPlan(id, version)
if (artifactsChanged) void refreshArtifacts(id, version)
}
function startStream(id: string, version: number) {
const cursor = events.value.at(-1)?.id || 0
stopStream = streamEvents(id, cursor, batch => {
applyEvents(batch, id, version)
void cacheEvents(batch)
if (version === loadVersion) streamError.value = ''
}, error => {
if (version === loadVersion && id === projectId.value) streamError.value = error.message
})
}
async function upload(options: { file: File; onSuccess: (value: unknown) => void; onError: (error: Error) => void }) {
try {
const file = await uploadFile(options.file)
options.onSuccess(file)
} catch (error) {
const failure = error instanceof Error ? error : new Error('上传失败')
options.onError(failure)
ElMessage.error(failure.message)
}
}
async function uploadFile(source: File, relativePath?: string) {
const form = new FormData()
form.append('file', source)
if (relativePath) form.append('relativePath', relativePath)
const file = await api<ProjectFile>(`/api/projects/${projectId.value}/files`, { method: 'POST', body: form })
files.value = [...files.value, file].sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'zh-CN'))
return file
}
async function uploadFolder(event: Event) {
const input = event.target as HTMLInputElement
const selected = Array.from(input.files || []).filter(file => {
const path = file.webkitRelativePath || file.name
const name = path.split('/').at(-1) || ''
return name !== '.DS_Store' && !name.startsWith('._')
})
input.value = ''
if (!selected.length || folderUploading.value) return
folderUploading.value = true
let cursor = 0
let uploaded = 0
const failures: string[] = []
const worker = async () => {
while (cursor < selected.length) {
const file = selected[cursor++]!
try {
await uploadFile(file, file.webkitRelativePath || file.name)
uploaded++
} catch (error) {
failures.push(error instanceof Error ? error.message : '上传失败')
}
}
}
try {
await Promise.all(Array.from({ length: Math.min(4, selected.length) }, worker))
if (uploaded) ElMessage.success(`已上传 ${uploaded} 个文件`)
if (failures.length) ElMessage.error(`${failures.length} 个文件上传失败:${failures[0]}`)
} finally {
folderUploading.value = false
}
}
async function startCheck() {
if (loading.value || folderUploading.value) return
loading.value = true
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/material-check`, { method: 'POST' })
runStatus.value = run.status
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '启动失败')
} finally {
loading.value = false
}
}
async function retry() {
if (plan.value?.status === 'CONFIRMED') {
loading.value = true
try {
await api(`/api/projects/${projectId.value}/runs/writing`, { method: 'POST' })
runStatus.value = 'RUNNING'
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重试失败')
} finally {
loading.value = false
}
return
}
await startCheck()
}
async function stopRun() {
if (!running.value || controlLoading.value) return
controlLoading.value = true
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/stop`, { method: 'POST' })
runStatus.value = run.status
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止失败')
} finally {
controlLoading.value = false
}
}
async function resumeRun() {
if (!interrupted.value || controlLoading.value) return
controlLoading.value = true
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' })
runStatus.value = run.status
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '继续失败')
} finally {
controlLoading.value = false
}
}
async function deleteProject() {
if (!project.value || running.value || deleting.value) return
try {
await ElMessageBox.confirm(
`删除“${project.value.companyName}”及全部材料和执行记录?`,
'删除项目',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
const id = projectId.value
deleting.value = true
try {
await api(`/api/projects/${id}`, { method: 'DELETE' })
loadVersion++
stopStream?.()
stopStream = null
localStorage.removeItem(`plan-draft:${plan.value?.id || ''}`)
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
try {
await deleteCachedEvents(id)
} catch {
ElMessage.warning('项目已删除,本地记录清理失败')
}
project.value = null
events.value = []
await router.replace('/projects')
emit('projects-changed')
ElMessage.success('项目已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
} finally {
deleting.value = false
}
}
async function confirmPlan(value: Record<string, unknown>) {
if (!plan.value || loading.value) return
loading.value = true
try {
const result = await api<{ plan: PlanView; run: { status: string } }>(`/api/projects/${projectId.value}/plan/confirm`, {
method: 'POST',
body: JSON.stringify({ planId: plan.value.id, plan: value })
})
localStorage.removeItem(`plan-draft:${plan.value.id}`)
plan.value = result.plan
pendingAsk.value = null
runStatus.value = result.run.status
await refreshProject()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '规划确认失败')
} finally {
loading.value = false
}
}
async function confirmMaterials(value: Record<string, unknown>) {
if (loading.value) return
loading.value = true
try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/material/confirm`, {
method: 'POST', body: JSON.stringify(value)
})
localStorage.removeItem(`material-ask:${String(pendingAsk.value?.interruptId || '')}`)
pendingAsk.value = null
runStatus.value = run.status
await refreshProject()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '材料确认失败')
} finally {
loading.value = false
}
}
function parseAsk(value?: string) {
if (!value) return null
try { return JSON.parse(value) as Record<string, any> } catch { return null }
}
async function refreshPlan(id = projectId.value, version = loadVersion) {
const value = await api<PlanView | null>(`/api/projects/${id}/plan`)
if (version === loadVersion && id === projectId.value) plan.value = value
}
async function refreshProject(id = projectId.value, version = loadVersion) {
const value = await api<Project>(`/api/projects/${id}`)
if (version === loadVersion && id === projectId.value) project.value = value
}
async function refreshArtifacts(id = projectId.value, version = loadVersion) {
const value = await api<Artifact[]>(`/api/projects/${id}/artifacts`)
if (version === loadVersion && id === projectId.value) artifacts.value = value
}
function updateScrollState() {
showBackToBottom.value = window.scrollY + window.innerHeight < document.documentElement.scrollHeight - 240
}
function scrollToBottom() {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })
}
watch(projectId, id => { if (id) void load(id) }, { immediate: true })
watch(() => events.value.length, async () => {
const followsTail = !showBackToBottom.value
await nextTick()
if (followsTail) window.scrollTo({ top: document.documentElement.scrollHeight })
})
onMounted(() => window.addEventListener('scroll', updateScrollState, { passive: true }))
onBeforeUnmount(() => {
loadVersion++
stopStream?.()
window.removeEventListener('scroll', updateScrollState)
})
</script>
<template>
<section v-if="project" class="project-page">
<header class="page-header">
<div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div>
<div class="run-actions">
<el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button>
<el-button v-else-if="interrupted" type="primary" plain :loading="controlLoading" @click="resumeRun">继续</el-button>
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
</div>
</header>
<div class="work-scroll">
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
<section v-else-if="!events.length" class="material-start">
<h2>企业材料</h2>
<el-upload drag multiple :show-file-list="false" :http-request="upload" class="upload-box">
<el-icon><UploadFilled /></el-icon>
<p>拖入企业材料或点击上传</p>
<small>支持 PDFDOCXXLSXPPTX图片</small>
</el-upload>
<div class="folder-upload">
<el-button :loading="folderUploading" @click="folderInput?.click()">上传文件夹</el-button>
<input ref="folderInput" type="file" multiple webkitdirectory aria-label="上传文件夹" @change="uploadFolder" />
</div>
<div v-if="files.length" class="file-chips">
<span v-for="file in files.slice(0, 12)" :key="file.id" :title="file.relativePath"><Document />{{ file.name }}</span>
<small v-if="files.length > 12"> {{ files.length }} 个文件</small>
</div>
<el-button type="primary" size="large" :loading="loading" :disabled="folderUploading" @click="startCheck">开始材料检验</el-button>
</section>
<AgentTimeline v-if="events.length" :events="events" :running="running" :project-id="projectId" />
<MaterialAskCard
v-if="waitingMaterials"
:ask="pendingAsk as any"
:loading="loading"
:upload-file="uploadFile"
@confirm="confirmMaterials"
/>
<PlanCard
v-if="waitingPlan"
:plan-id="plan!.id"
:plan="plan!.plan"
:loading="loading"
@confirm="confirmPlan"
/>
<section v-if="artifacts.length" class="artifact-card">
<div v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
<Document />
<div><strong>{{ artifact.name }}</strong><small>{{ Math.ceil(artifact.sizeBytes / 1024) }} KB</small></div>
<a :href="`/api/artifacts/${artifact.id}/download`">下载</a>
</div>
</section>
<el-button v-if="project.status === 'FAILED' && !running" class="retry-button" :loading="loading" @click="retry">重新运行</el-button>
<button v-if="streamError" class="stream-error" @click="load(projectId)">连接中断点击重连</button>
</div>
<button v-if="showBackToBottom" class="back-to-bottom" aria-label="回到底部" @click="scrollToBottom"></button>
</section>
<section v-else class="empty-main">新建或选择一个项目</section>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
interface SkillView {
name: string
description: string
version: string
sourceType: 'BUILTIN' | 'IMPORTED'
enabled: boolean
readOnly: boolean
validationStatus: string
}
interface SkillDetail { view: SkillView; content: string; resources: string[] }
const skills = ref<SkillView[]>([])
const selectedName = ref('')
const detail = ref<SkillDetail | null>(null)
const tab = ref('content')
const selected = computed(() => skills.value.find(skill => skill.name === selectedName.value))
async function load() {
skills.value = await api<SkillView[]>('/api/skills')
await select(selectedName.value || skills.value[0]?.name || '')
}
async function select(name: string) {
selectedName.value = name
detail.value = name ? await api<SkillDetail>(`/api/skills/${encodeURIComponent(name)}`) : null
}
async function toggle(skill: SkillView) {
const enabled = !skill.enabled
skill.enabled = enabled
try {
await api(`/api/skills/${encodeURIComponent(skill.name)}/${enabled ? 'enable' : 'disable'}`, { method: 'POST' })
} catch (error) {
skill.enabled = !enabled
ElMessage.error(error instanceof Error ? error.message : '设置失败')
}
}
async function importSkill(options: { file: File; onSuccess: (value: unknown) => void; onError: (error: Error) => void }) {
const data = new FormData()
data.append('file', options.file)
try {
const value = await api('/api/skills/import', { method: 'POST', body: data })
options.onSuccess(value)
ElMessage.success('已导入')
await load()
} catch (error) {
const failure = error instanceof Error ? error : new Error('导入失败')
options.onError(failure)
ElMessage.error(failure.message)
}
}
onMounted(load)
</script>
<template>
<section class="settings-page skills-page">
<header class="skills-header">
<div><h1>Skills</h1><p>导入查看并控制 Agent 可用的技能</p></div>
<el-upload :show-file-list="false" accept=".zip" :http-request="importSkill">
<span class="import-skill-trigger"><el-icon><Upload /></el-icon>导入 Skill</span>
</el-upload>
</header>
<div class="skill-grid">
<aside class="skill-list">
<h2>已导入 {{ skills.length }} </h2>
<div v-for="skill in skills" :key="skill.name" class="skill-item" :class="{ selected: selectedName === skill.name }">
<button class="skill-select" @click="select(skill.name)">
<span><strong>{{ skill.name }}</strong><small>{{ skill.description }}</small></span>
<small>{{ skill.version || '—' }}</small>
<i>有效</i>
</button>
<el-switch :model-value="skill.enabled" :aria-label="`启用 ${skill.name}`" @click.stop="toggle(skill)" />
</div>
</aside>
<article v-if="detail && selected" class="skill-detail">
<div class="skill-title"><div><h2>{{ selected.name }}</h2><span class="tag">{{ selected.version || '未标版本' }}</span><span class="tag">只读</span><span class="tag green">{{ selected.enabled ? '已启用' : '已停用' }}</span></div><el-button @click="toggle(selected)">{{ selected.enabled ? '停用' : '启用' }}</el-button></div>
<el-tabs v-model="tab">
<el-tab-pane label="SKILL.md" name="content"><pre>{{ detail.content }}</pre></el-tab-pane>
<el-tab-pane label="资源" name="resources"><div class="resource-list"><span v-for="resource in detail.resources" :key="resource">{{ resource }}</span><p v-if="!detail.resources.length">无资源文件</p></div></el-tab-pane>
<el-tab-pane label="校验" name="validation"><p class="validation-ok">结构有效 · 未发现错误</p></el-tab-pane>
</el-tabs>
</article>
</div>
</section>
</template>

13
web-ui/src/router.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
{ path: '/', redirect: '/projects' },
{ path: '/projects', component: () => import('./pages/ProjectPage.vue') },
{ path: '/projects/:id', component: () => import('./pages/ProjectPage.vue') },
{ path: '/models', component: () => import('./pages/ModelsPage.vue') },
{ path: '/skills', component: () => import('./pages/SkillsPage.vue') }
]
})

247
web-ui/src/styles.css Normal file
View File

@@ -0,0 +1,247 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #111827;
background: #fff;
font-synthesis: none;
--blue: #0f5df5;
--blue-soft: #f2f7ff;
--muted: #667085;
--line: #e8edf5;
--green: #087d41;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 0; min-height: 100vh; }
button, input, textarea { font: inherit; }
a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; display: flex; background: #fff; }
.rail { width: 84px; flex: 0 0 84px; height: 100vh; position: sticky; top: 0; border-right: 1px solid var(--line); display: flex; flex-direction: column; align-items: center; padding: 20px 10px; gap: 18px; }
.brand { width: 46px; height: 46px; border-radius: 9px; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 700; }
.brand svg { width: 24px; }
.rail a { width: 64px; height: 66px; border-radius: 9px; color: #405170; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; font-size: 15px; }
.rail a svg { width: 24px; height: 24px; }
.rail a.active { background: var(--blue); color: #fff; }
.rail .brand + a { margin-top: 6px; }
.project-list { width: 276px; flex: 0 0 276px; height: 100vh; position: sticky; top: 0; overflow-y: auto; border-right: 1px solid var(--line); padding: 24px 16px; background: #fff; }
.aside-title { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
.aside-title h2 { font-size: 20px; margin: 0; }
.icon-button { width: 32px; height: 32px; border: 1px solid #9aa8bd; border-radius: 50%; background: #fff; color: #395277; font-size: 23px; line-height: 27px; cursor: pointer; }
.aside-label { margin: 32px 12px 12px; color: #596883; font-size: 14px; }
.project-item { display: flex; flex-direction: column; gap: 8px; padding: 16px 14px; border-radius: 8px; margin-bottom: 7px; }
.project-item span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.project-item time { color: #697794; font-size: 13px; }
.project-item.selected { background: #edf4ff; color: var(--blue); }
.aside-empty { padding: 20px 12px; color: #98a2b3; }
.main-view { min-width: 0; flex: 1; }
.project-page { min-height: 100vh; display: flex; flex-direction: column; }
.page-header { height: 80px; flex: 0 0 80px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 32px; background: rgba(255,255,255,.95); position: sticky; top: 0; z-index: 3; }
.page-heading, .run-actions { display: flex; align-items: center; gap: 12px; }
.page-header h1 { margin: 0; font-size: 25px; letter-spacing: -.02em; }
.tag { display: inline-flex; align-items: center; height: 30px; padding: 0 11px; border: 1px solid #cfd7e4; border-radius: 6px; color: #52617b; font-size: 14px; font-weight: 500; white-space: nowrap; }
.tag.blue { color: var(--blue); border-color: #9dbdff; }
.tag.green { color: var(--green); border-color: #9be0bd; }
.work-scroll { width: min(920px, calc(100% - 64px)); margin: 0 auto; padding: 32px 0 72px; }
.history-loading { margin-top: 24vh; text-align: center; color: var(--muted); }
.material-start { width: 640px; max-width: 100%; margin: 10vh auto 0; }
.material-start h2 { font-size: 24px; margin: 0 0 24px; }
.upload-box .el-upload-dragger { border: 1px dashed #a9b8cf; background: #fbfdff; padding: 44px; }
.upload-box .el-icon { color: var(--blue); font-size: 34px; }
.upload-box p { margin: 14px 0 4px; font-size: 16px; }
.upload-box small { color: #8995a8; }
.folder-upload { display: flex; justify-content: center; margin-top: 12px; }
.folder-upload input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.file-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
.file-chips span { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 14px; }
.file-chips svg { width: 16px; color: var(--blue); }
.file-chips > small { align-self: center; color: var(--muted); }
.timeline { display: flex; flex-direction: column; gap: 12px; }
.load-earlier { align-self: center; border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 8px 16px; }
.load-earlier:hover, .load-earlier:focus-visible { color: var(--primary); }
.flow-row { min-width: 0; content-visibility: auto; contain-intrinsic-size: 54px; }
.flow-message { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; margin: 0; }
.flow-message > .flow-content { grid-column: 2; }
.flow-tool, .flow-reasoning, .flow-notice { position: relative; padding-left: 54px; }
.agent-avatar { width: 38px; height: 38px; border-radius: 11px; background: #edf4ff; color: var(--blue); display: grid; place-items: center; }
.agent-avatar svg { width: 21px; height: 21px; stroke-width: 1.8; }
.flow-content { min-width: 0; }
.flow-meta { display: flex; gap: 10px; align-items: baseline; margin: 1px 0 8px; }
.flow-meta time { color: #6c7b95; font-size: 13px; }
.agent-markdown { min-width: 0; color: #253247; font-size: 15px; line-height: 1.75; overflow-wrap: anywhere; }
.agent-markdown > :first-child { margin-top: 0; }
.agent-markdown > :last-child { margin-bottom: 0; }
.agent-markdown p.paragraph-node { margin: 0; }
.agent-markdown > .node-slot + .node-slot { margin-top: 12px; }
.agent-markdown h1, .agent-markdown h2, .agent-markdown h3 { margin: 20px 0 10px; line-height: 1.35; color: #182235; }
.agent-markdown h1 { font-size: 21px; }
.agent-markdown h2 { font-size: 18px; }
.agent-markdown h3 { font-size: 16px; }
.agent-markdown ul, .agent-markdown ol { margin: 8px 0 12px; padding-left: 24px; }
.agent-markdown li + li { margin-top: 4px; }
.agent-markdown blockquote { margin: 12px 0; padding-left: 14px; border-left: 2px solid #d8e0eb; color: #5d687b; }
.agent-markdown code { padding: 2px 5px; border-radius: 4px; background: #f2f4f7; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.agent-markdown pre { margin: 12px 0; padding: 14px 16px; overflow-x: auto; border-radius: 7px; background: #f5f7fa; }
.agent-markdown pre code { padding: 0; background: transparent; }
.agent-markdown a { color: var(--blue); text-decoration: underline; text-underline-offset: 3px; }
.agent-markdown table { display: block; max-width: 100%; margin: 12px 0; overflow-x: auto; border-collapse: collapse; }
.agent-markdown th, .agent-markdown td { padding: 7px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
.activity-row { color: #778195; font-size: 13px; border: 0; }
.activity-row summary { width: max-content; max-width: 100%; display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; list-style: none; }
.activity-row summary::-webkit-details-marker { display: none; }
.activity-row summary::after { content: ""; margin-left: 2px; color: #a1a9b7; transition: transform .16s ease; }
.activity-row[open] summary::after { transform: rotate(90deg); }
.activity-row summary > svg { width: 15px; height: 15px; flex: 0 0 15px; color: currentColor; stroke-width: 1.8; }
.activity-row p { margin: 6px 0 2px 23px; color: #647084; font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
.activity-row.failed { color: #b54747; }
.activity-row.active summary span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
.view-image-strip { display: flex; flex-wrap: nowrap; gap: 10px; margin: 8px 0 4px 23px; overflow-x: auto; padding: 0 0 8px; }
.view-image-item { flex: 0 0 168px; min-width: 0; padding: 0; border: 0; color: #657187; background: transparent; text-align: left; cursor: zoom-in; }
.view-image-item:focus-visible { border-radius: 7px; outline: 2px solid var(--blue); outline-offset: 3px; }
.view-image-strip img { display: block; width: 168px; height: 98px; object-fit: cover; border-radius: 7px; background: #f4f6f8; }
.view-image-strip span { display: block; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.finalizing-row { margin-left: 54px; color: #778195; font-size: 13px; }
.finalizing-row span { color: transparent; background: linear-gradient(90deg, #8d96a6 25%, #3f83ed 50%, #8d96a6 75%); background-size: 220% 100%; background-clip: text; animation: activity-shimmer 1.7s linear infinite; }
@keyframes activity-shimmer { to { background-position: -220% 0; } }
@media (prefers-reduced-motion: reduce) { .activity-row.active summary span, .finalizing-row span { color: inherit; background: none; animation: none; } }
.notice-row { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-radius: 8px; background: #f6f9ff; }
.notice-row svg { width: 18px; }
.notice-row.success { color: #087d41; }
.notice-row.error { color: #c53131; background: #fff7f7; }
.notice-row.info { color: #45658f; background: #f7f9fc; }
.pending { opacity: .9; }
.ask-card { width: 700px; max-width: calc(100% - 60px); margin: 28px 0 0 60px; padding: 22px; border-radius: 9px; background: #f6f9ff; box-shadow: inset 0 0 0 1px #d7e4fb; }
.ask-card h3 { color: var(--blue); margin: 0 0 5px; font-size: 20px; }
.ask-card > p { color: #697794; margin: 0 0 16px; font-size: 14px; }
.plan-fields { overflow: hidden; border-radius: 6px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
.plan-fields label { display: grid; grid-template-columns: 150px 1fr; align-items: center; min-height: 52px; border-bottom: 1px solid var(--line); }
.plan-fields label:last-child { border: 0; }
.plan-fields label > span { padding-left: 16px; }
.plan-fields .el-input__wrapper { box-shadow: none; }
.plan-fields .el-input-number { width: 100%; }
.plan-fields .el-input-number .el-input__wrapper { box-shadow: none; }
.plan-summary { margin: 12px 0 16px; padding: 11px 14px; border-radius: 6px; color: #4b6188; background: #eaf2ff; }
.plan-note { display: block; margin-bottom: 16px; }
.plan-note > span { display: block; margin-bottom: 8px; color: #53617b; font-size: 14px; }
.ask-actions { display: flex; gap: 8px; }
.material-items { margin-bottom: 16px; overflow: hidden; border-radius: 7px; background: #fff; box-shadow: inset 0 0 0 1px #dce4ef; }
.material-item { min-height: 72px; display: grid; grid-template-columns: minmax(180px, 1fr) 190px 58px; gap: 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.material-item:last-child { border-bottom: 0; }
.material-item > div { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.material-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.material-item small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.material-item small.field-error { color: #b42318; }
.material-upload { color: var(--blue); cursor: pointer; text-align: center; }
.material-upload.busy { color: var(--muted); }
.material-upload input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.material-clear { margin: 0; padding: 16px; color: var(--green); }
.material-more { width: 100%; padding: 11px; border: 0; border-top: 1px solid var(--line); background: #fff; color: var(--blue); cursor: pointer; }
.material-upload:focus-within, .material-more:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
.artifact-card { margin: 28px 0 min(28vh, 280px) 60px; border-radius: 8px; overflow: hidden; box-shadow: inset 0 0 0 1px #dce4ef; }
.artifact-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--line); }
.artifact-row:last-child { border: 0; }
.artifact-row > svg { width: 25px; color: var(--blue); }
.artifact-row div { display: flex; flex-direction: column; gap: 4px; }
.artifact-row small { color: #7b879b; }
.artifact-row a { color: var(--blue); }
.stream-error { display: block; border: 0; background: transparent; color: #c53131; margin: 24px auto; cursor: pointer; }
.retry-button { display: block; margin: 28px auto 0; }
.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; }
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
.settings-page { min-height: 100vh; padding: 28px 42px; }
.settings-page > header h1 { margin: 0; font-size: 28px; }
.settings-page > header p { color: #5f6d86; margin: 10px 0 0; }
.settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; }
.settings-list { padding-right: 28px; border-right: 1px solid var(--line); }
.settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; }
.settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; }
.settings-list button.selected { background: #edf4ff; color: var(--blue); }
.settings-list button strong, .settings-list button span { grid-column: 1; }
.settings-list button span { margin-top: 8px; color: #5f6d86; }
.settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); }
.settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
.settings-form { padding-left: 28px; }
.form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); }
.form-title h2 { margin: 0; font-size: 22px; }
.settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); }
.capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); }
.capability-row div { display: flex; gap: 8px; }
.capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; }
.model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; }
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
.connection-ok svg { width: 18px; }
.skills-header { display: flex; justify-content: space-between; align-items: start; }
.import-skill-trigger { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 15px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: #fff; cursor: pointer; }
.import-skill-trigger:hover, .skills-header .el-upload:focus-visible .import-skill-trigger { border-color: var(--primary); color: var(--primary); }
.skill-grid { display: grid; grid-template-columns: 480px minmax(540px, 1fr); margin-top: 24px; min-height: 780px; }
.skill-list { border-right: 1px solid var(--line); padding-right: 16px; overflow-y: auto; max-height: calc(100vh - 150px); }
.skill-item { min-height: 84px; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-bottom: 1px solid var(--line); background: #fff; }
.skill-item.selected { background: #edf4ff; }
.skill-select { min-width: 0; min-height: 84px; display: grid; grid-template-columns: minmax(0, 1fr) 52px 54px; align-items: center; gap: 8px; border: 0; background: transparent; text-align: left; padding: 14px; cursor: pointer; }
.skill-select > span { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.skill-select strong, .skill-select span > small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.skill-select strong { color: #1f2a3d; }
.skill-select span > small { color: #667085; font-size: 13px; }
.skill-select > i { color: var(--green); font-style: normal; white-space: nowrap; }
.skill-select > i::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 5px; }
.skill-detail { padding-left: 28px; }
.skill-title { min-height: 72px; display: flex; justify-content: space-between; align-items: start; gap: 12px; }
.skill-title > div { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; }
.skill-title h2 { margin: 0 6px 0 0; font-size: 24px; }
.skill-detail pre { min-height: 450px; max-height: calc(100vh - 280px); overflow: auto; white-space: pre-wrap; margin: 8px 0 0; padding: 22px; border-radius: 7px; background: #fafbfd; color: #293752; line-height: 1.7; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; box-shadow: inset 0 0 0 1px #e1e7f0; }
.resource-list { display: flex; flex-direction: column; gap: 8px; }
.resource-list span { padding: 12px; border-bottom: 1px solid var(--line); }
.validation-ok { color: var(--green); }
.login-page { min-height: 100vh; display: grid; place-items: center; background: #f8faff; }
.login-panel { width: 360px; padding: 36px; display: flex; flex-direction: column; gap: 16px; border-radius: 14px; background: #fff; box-shadow: 0 12px 44px rgba(34, 64, 120, .08); }
.login-panel .brand.large { margin: 0 auto; }
.login-panel h1 { margin: 4px 0 10px; text-align: center; font-size: 24px; }
.full-button { width: 100%; }
.form-error { color: #c53131; font-size: 13px; margin: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.el-button--primary { --el-button-bg-color: var(--blue); --el-button-border-color: var(--blue); }
.el-input__wrapper, .el-textarea__inner { box-shadow: inset 0 0 0 1px #d9e0ea; }
.el-dialog { border-radius: 12px; }
@media (max-width: 1180px) {
.project-list { width: 240px; flex-basis: 240px; }
.settings-page { padding-left: 28px; padding-right: 28px; }
.settings-grid { grid-template-columns: 310px 1fr; }
.skill-grid { grid-template-columns: 390px 1fr; }
.skill-title { min-height: 100px; }
.skill-title > div { align-items: flex-start; flex-wrap: wrap; }
.skill-title h2 { flex-basis: 100%; font-size: 21px; overflow-wrap: anywhere; }
}
@media (max-width: 720px) {
.rail { width: 64px; flex-basis: 64px; padding-left: 0; padding-right: 0; }
.rail a { width: 52px; font-size: 12px; }
.project-list { width: 190px; flex-basis: 190px; padding: 20px 10px; }
.work-scroll { width: calc(100% - 32px); }
.page-header { padding: 0 16px; }
.page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
.ask-card, .artifact-card { max-width: 100%; margin-left: 0; }
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
.flow-tool::before, .flow-reasoning::before { left: 7px; }
.flow-tool::after, .flow-reasoning::after { left: 4px; }
.material-item { grid-template-columns: 1fr 160px; }
.material-upload { grid-column: 2; }
.settings-page { padding: 24px 16px; }
.settings-grid, .skill-grid { grid-template-columns: 1fr; }
.settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; }
.settings-form, .skill-detail { padding: 24px 0 0; }
.settings-form > label, .capability-row { grid-template-columns: 130px 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

18
web-ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

7
web-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

11
web-ui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}

21
web-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8080'
}
},
build: {
target: 'es2022',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: { vue: ['vue', 'vue-router'] }
}
}
}
})