Compare commits
No commits in common. "054aaf8e8cbe3ebf668a2a72b6e8d568afd822ab" and "b555059bc4ad57bc42329e9644f1766b3ae465c2" have entirely different histories.
054aaf8e8c
...
b555059bc4
|
|
@ -4,10 +4,6 @@
|
|||
> 维护口径:只记**用户可感知**的变化("能干什么了"而不是"改了哪个模块"),纯内部重构 / 修复不记,
|
||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
## 0.65.2 — 2026-08-13
|
||||
|
||||
- 粘贴、拖拽或选择附件发送后,对话中的用户消息会保留附件卡片;图片同时显示可点击放大的缩略图,刷新历史对话后也不会消失。
|
||||
|
||||
|
||||
## 0.65.1 — 2026-08-12
|
||||
|
||||
|
|
|
|||
|
|
@ -175,8 +175,6 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
|
|||
|
||||
**对话产物与生命周期(0025/0028)**:真实文件仍是内容事实源;`artifacts` 表记录已发布产物的稳定身份和生命周期,包含 user-root 相对当前路径、来源 task、复制来源、哈希/大小及 active/deleted、回收路径。新 `messages.artifact_refs` 使用 `{version:2, artifact_id, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`;`path` 是兼容快照,预览/下载优先按 `artifact_id` 找当前路径,因此移动或重命名后历史卡片仍有效。version 1 和 `NULL` 旧消息继续走原 task-scoped 兼容链。普通源码树、中间文件和配套资源不登记;agent 仅用 `publish_artifacts` 显式提升少量最终文件。移动保持身份,复制为每个副本创建新身份并记录直接来源;删除将文件移入 `.zcbot_artifact_trash/` 并软删记录,普通文件仍物理删除。
|
||||
|
||||
**用户消息附件(0031)**:`messages.attachment_refs` 与助手产物分开表达输入附件,元素为 `{version:1, scope:"working_dir", path, label, kind, media_type, size_bytes}`;文件仍是事实源,不登记为已发布 artifact,也不承诺独立生命周期。`payload.content` 只保存用户自然语言,模型上下文在内存中按附件类型补兼容提示,避免 UI 协议污染正文。`NULL` 表示旧客户端/旧历史,前端继续解析正文标记;`[]` 表示新消息明确无附件。独立 attachment 表暂缓,只有出现跨消息复用、稳定身份、版本快照或附件级审计需求时再抽象 `file_assets + message_attachments`。
|
||||
|
||||
### 7.2 资源模型(/v1)
|
||||
|
||||
统一 `/v1` 前缀返 JSON;UI 由 platform 实现(§7.9),本地 dev SPA dogfood。要点(细节见 `web/app.py`):
|
||||
|
|
@ -224,8 +222,8 @@ tasks(task_id pk, user_id fk, name NOT NULL, auto_title_pending default false,
|
|||
context_base_idx, -- 0019 §8.8 软重置窗口起点
|
||||
deleted_at, -- 0010 软删
|
||||
created_at, updated_at)
|
||||
messages(pk, task_id fk, idx, payload jsonb, artifact_refs jsonb null, attachment_refs jsonb null,
|
||||
tokens_in/out, model_profile, kind, -- 0025/0031 UI 元数据;kind=push 等
|
||||
messages(pk, task_id fk, idx, payload jsonb, artifact_refs jsonb null, -- 0025,task-relative UI 元数据
|
||||
tokens_in/out, model_profile, kind, -- kind=push 等
|
||||
unique(task_id, idx); gin(payload))
|
||||
usage_events(pk, user_id, task_id, message_id, kind, -- chat/image/video/vision/... 自由文本
|
||||
model_profile, units jsonb, cost numeric, created_at) -- 多态用量,加媒体不动 schema
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-13(用户消息结构化附件与图片预览,bump 0.65.2)
|
||||
最后更新:2026-08-12(Windows Node 列表与生命周期管理,bump 0.65.1)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -18,12 +18,9 @@
|
|||
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;D `/v1` JSON API ✅;D' 过渡 auth + dev SPA ✅;单活 run 锁 + cancel ✅;0004 schema 瘦身 ✅;入口归位 ✅;真 OIDC 待;**C Step 1-3 + 3d ✅(Executor + Docker 池 + DockerExecutor + fs 工具进容器)+ Step 5 部署前置对账 ✅ + 容器资源 yaml + 应用层磁盘配额 ✅ + dogfood 网络放开 + 容器内 pip/npm 源持久化 ✅**;**Step 4 完整 egress proxy + Step 3b PGID kill 协议延后到外部用户开放前**(还需 egress proxy + xfs project quota OS 层硬化,§7.5 落地清单 #2 #4)。 |
|
||||
|
||||
---
|
||||
|
||||
## 已完成关键能力
|
||||
|
||||
### 2026-08-13
|
||||
|
||||
- **08-13 / 0.65.2 / 用户消息结构化附件 + 对话内图片预览**:新增 0031 `messages.attachment_refs`,新客户端将附件作为结构化字段发送,数据库正文仅保留用户自然语言;后端按 task working_dir 校验路径并在内存模型上下文中补附件提示,旧客户端与历史正文标记继续兼容。用户消息即时态和历史态统一展示附件 chip,图片额外显示可点击缩略图;相关 Python 34 项、全部前端 Node 26 项、Python/JavaScript 语法、Alembic 单 head 与 diff 检查通过,未连接或迁移生产 DB。
|
||||
|
||||
### 2026-08-12
|
||||
|
||||
- **08-12 / 0.65.1 / Windows Node 列表与生命周期管理**:管理后台按 10 秒轮询展示节点名称/ID、在线状态、系统与桌面会话、版本、能力和最近心跳,并提供可恢复的禁用/启用与永久删除;禁用或删除在线节点先主动关闭 WebSocket,删除后的本机身份必须清除并重新注册。生产实测补齐 nginx 节点 WebSocket Upgrade 专用 location 与故障说明,避免注册成功后长连接被普通 HTTP location 当作 GET。相关后端、鉴权路由和管理前端 45 项 unittest、Python 编译、JavaScript 语法与 diff 检查通过;无 schema、migration 或依赖变化,未连接或写入生产 DB。
|
||||
|
|
|
|||
5
RUN.md
5
RUN.md
|
|
@ -152,7 +152,6 @@
|
|||
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
|
||||
- **OpenAPI / MCP 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`。③ admin 进入管理后台「外部系统」,选择通用 OpenAPI 或通用 MCP;具体 MES/ERP/LIMS 都作为数据库 definition 配置,不新增专用 provider。MCP 填写与登录 Base URL 同源的 Streamable HTTP URL,可选填写期望 Server 名称;连接后以 `tools/list` 为事实源。④ 普通用户点击左栏 **「外部」**,页面按 definition 动态显示用户名密码、API Key 或 Bearer Token;目标、Server 身份、登录、认证绑定或 TLS 变化后保留密文并暂停调用,重新测试成功后恢复。OpenAPI spec 和 MCP tool catalog 只在进程内按连接身份有界缓存,登录与业务响应均限长,普通用户和模型不能传任意 URL。
|
||||
- **Artifact 生命周期(0028)**:部署本版本必须先执行 `.venv/Scripts/python.exe main.py db upgrade head`。migration 会从存量 `messages.artifact_refs` 回填 active artifact 身份;删除后的已发布产物保存在用户根目录隐藏区 `.zcbot_artifact_trash/`,默认不自动清理且继续计入磁盘配额。普通文件删除语义不变。
|
||||
- **用户消息结构化附件(0031)**:部署新版前执行 `.venv/Scripts/python.exe main.py db upgrade head`,为 `messages` 增加 nullable `attachment_refs`。migration 不回写或改动存量正文;旧客户端仍可只传 `content`,新版会发送结构化 `attachments`。
|
||||
- **旧 `factory_mes` definition 一次性转换**:新版代码不再识别 `factory_mes`;部署时保持旧服务进程运行,先从新代码目录执行数据脚本,转换成功后再重启到新版。脚本不加载 `.env`、不读取 `ZCBOT_DB_URL`,只认显式的 `ZCBOT_MIGRATION_DB_URL`;默认 dry-run,检查同名冲突与配置合法性。确认输出后加 `--apply`,脚本把 definition 转为 `generic_openapi + query`、物化 JWT/提示/只读 POST 配置并同步 active connection revision,不解密或改写用户凭据。
|
||||
```powershell
|
||||
$env:ZCBOT_MIGRATION_DB_URL="postgresql+psycopg://user:pass@host:5432/zcbot"
|
||||
|
|
@ -362,8 +361,8 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
|
|||
| `DELETE /v1/external-systems/{id}` | 清除当前用户连接及密文凭据;指定用户模式保留管理员授予的可见权 | 必填 |
|
||||
| `GET/POST /v1/admin/external-system-definitions` | 管理员列出或新增可信外部系统目录 | admin |
|
||||
| `PUT/DELETE /v1/admin/external-system-definitions/{id}` | 管理员编辑、停用或删除目录项;已有用户连接时拒绝删除 | admin |
|
||||
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传;另带 `artifact_refs`(助手产物)与 `attachment_refs`(用户附件)。两者均以 `null` 表示旧消息、`[]` 表示新消息明确为空、非空数组表示 task-relative 结构化引用 | 必填 |
|
||||
| `POST /v1/tasks/{id}/messages` | `{content, attachments?:[{path,kind,label?}], image_model?=""}` 发消息;`attachments` 路径由后端按当前 task working_dir 校验并规范化,允许纯附件消息;旧客户端省略该字段继续兼容。返 `{events_url}`;**`run_status` 是 running/cancelling → 409**;UI 应 disable send 直到 SSE `done` | 必填 |
|
||||
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传;0025 起每条另带 `artifact_refs`:`null`=旧消息、`[]`=新消息无产物、非空数组=相对该 task 当前 working_dir 的结构化产物引用 | 必填 |
|
||||
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
|
||||
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
|
||||
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
|
||||
| `GET /v1/procs` | 当前用户全部后台进程(bg proc,§8.12;shell/run_python `background=true` 启动);纯文件系统读取,前端运行条 5s 轮询用 | 必填 |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.65.2"
|
||||
__version__ = "0.65.1"
|
||||
|
|
|
|||
|
|
@ -53,8 +53,7 @@ from tools.office_to_pdf import soffice_available
|
|||
# 二选一(seedream=豆包 / gpt_image=unifyllm 网关),同一 run 只挂一个图像工具。
|
||||
_MEDIA_LOOK_SEG = """\
|
||||
- `look_at_image` —— 看图 / 读图(豆包 Seed 2.0 Lite 视觉)。**你(主模型)是纯文本看不见图,要"看"图就调它**:OCR 文字、描述画面、读图表/表格/示意图、识别物体。**很慢(一次要几十秒),谨慎调用** —— 只在确实需要图的实际内容才调。
|
||||
- **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答(问"这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)的**实际内容**做事(不是改图,改图走 seedream)。传 `image=<路径>`,并且**必须在 `question` 里一次写清完成当前任务所需的具体信息**,别分多次看。
|
||||
- **问题范围**:只问完成用户任务所需内容。例如问仪表读数就只读数,问某列数据就只提取该列;仅当用户明确要求"全文 OCR / 转文字 / 完整识别整张图"时,才要求逐字识别全部文字。不要无条件同时要求画面描述、全文 OCR 和图表解析。
|
||||
- **何时调**:用户消息里出现 `[用户上传的参考图] <路径>` 且需要据图内容回答(问"这图里写了啥 / 是什么 / 表格数据多少");或要基于 task 内某张图(`figures/xxx.png`)的**实际内容**做事(不是改图,改图走 seedream)。传 `image=<路径>` + 可选 `question`,**把想知道的一次在 `question` 里问全**,别分多次看。
|
||||
- **何时不调**:用户只是要改图(走 seedream i2i)/ 只要文件名不关心内容 / 图是你自己刚生成的且 prompt 已知(无需再读)/ 图的内容对当前任务可有可无。**绝不对同一张图反复看**(每看一次都是几十秒等待)。"""
|
||||
_MEDIA_READDOC_SEG = """\
|
||||
- `read_document` —— 读 PDF(豆包 Seed 2.0 Lite 文档理解),**专治扫描件**:markitdown 对某 PDF 转出**空 / 近空**(纯图页无文本层)→ 用它逐页 OCR 成 markdown。每页约 1-2 厘钱,单次上限 100 页(更长先拆分卷)。
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
"""Structured user-message attachments and model-context compatibility rendering."""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .artifacts import ArtifactPathError, resolve_artifact_path
|
||||
|
||||
ATTACHMENT_REF_VERSION = 1
|
||||
MAX_ATTACHMENTS_PER_MESSAGE = 10
|
||||
_IMAGE_EXTENSIONS = {
|
||||
".avif", ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg",
|
||||
".png", ".svg", ".tif", ".tiff", ".webp",
|
||||
}
|
||||
|
||||
|
||||
def _attachment_kind(requested: str, path: Path, media_type: str) -> str:
|
||||
detected_image = media_type.startswith("image/") or path.suffix.lower() in _IMAGE_EXTENSIONS
|
||||
if requested == "image" and detected_image:
|
||||
return "image"
|
||||
return "file"
|
||||
|
||||
|
||||
def normalize_attachment_refs(
|
||||
refs: Iterable[Any], *, working_dir: Path, user_root: Path,
|
||||
) -> list[dict]:
|
||||
"""Validate client refs and return task-relative, display-ready metadata."""
|
||||
output: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for raw in refs:
|
||||
if len(output) >= MAX_ATTACHMENTS_PER_MESSAGE:
|
||||
raise ArtifactPathError(
|
||||
f"at most {MAX_ATTACHMENTS_PER_MESSAGE} attachments are allowed"
|
||||
)
|
||||
data = raw.model_dump() if hasattr(raw, "model_dump") else dict(raw or {})
|
||||
absolute, rel = resolve_artifact_path(
|
||||
str(data.get("path") or ""),
|
||||
working_dir=working_dir,
|
||||
user_root=user_root,
|
||||
require_file=True,
|
||||
)
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
media_type = mimetypes.guess_type(absolute.name)[0] or "application/octet-stream"
|
||||
label = str(data.get("label") or absolute.name).strip() or absolute.name
|
||||
output.append({
|
||||
"version": ATTACHMENT_REF_VERSION,
|
||||
"scope": "working_dir",
|
||||
"path": rel,
|
||||
"label": label,
|
||||
"kind": _attachment_kind(str(data.get("kind") or "file"), absolute, media_type),
|
||||
"media_type": media_type,
|
||||
"size_bytes": absolute.stat().st_size,
|
||||
})
|
||||
return output
|
||||
|
||||
|
||||
def content_for_model(content: str, refs: Iterable[dict], working_dir_name: str) -> str:
|
||||
"""Append the legacy-readable file hints only to the provider-bound message."""
|
||||
lines: list[str] = []
|
||||
wd = str(working_dir_name or "").strip().strip("/\\")
|
||||
for ref in refs or ():
|
||||
path = str((ref or {}).get("path") or "").strip().replace("\\", "/")
|
||||
if not path:
|
||||
continue
|
||||
full_path = f"{wd}/{path}" if wd else path
|
||||
marker = "[用户上传的参考图]" if (ref or {}).get("kind") == "image" else "[用户上传的文件]"
|
||||
lines.append(f"{marker} {full_path}")
|
||||
text = str(content or "").strip()
|
||||
if not lines:
|
||||
return text
|
||||
suffix = "\n".join(lines)
|
||||
return f"{text}\n\n{suffix}" if text else suffix
|
||||
|
|
@ -226,16 +226,6 @@ class Session:
|
|||
).scalars().all()
|
||||
for row in rows:
|
||||
payload = dict(row.payload)
|
||||
attachment_refs = getattr(row, "attachment_refs", None)
|
||||
if payload.get("role") == "user" and attachment_refs:
|
||||
from .attachments import content_for_model
|
||||
working_dir = str((sess.meta or {}).get("working_dir") or "")
|
||||
wd_name = working_dir.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
||||
payload["content"] = content_for_model(
|
||||
str(payload.get("content") or ""),
|
||||
attachment_refs,
|
||||
wd_name,
|
||||
)
|
||||
if payload.get("role") == "assistant" and isinstance(payload.get("content"), str):
|
||||
# 历史行不回写生产库;只在重建 LLM 上下文时应用同一窄修复,
|
||||
# 与 Web 展示层保持一致,避免旧坏围栏继续污染后续轮次。
|
||||
|
|
|
|||
|
|
@ -192,10 +192,6 @@ class Message(Base):
|
|||
# fall back to path extraction); [] means a new message explicitly published no artifacts.
|
||||
# Kept outside payload so provider-bound conversation messages remain protocol-clean.
|
||||
artifact_refs: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
|
||||
# Structured user input attachments. NULL means legacy; [] means explicitly none.
|
||||
attachment_refs: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
tokens_in: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
tokens_out: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
# 0006:产生该 message 的模型(只在 assistant 行有值;user/tool/system 为 NULL)。
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
"""Add structured user attachment references to messages.
|
||||
|
||||
Revision ID: 0031
|
||||
Revises: 0030
|
||||
Create Date: 2026-08-13
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0031"
|
||||
down_revision: str | None = "0030"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column("attachment_refs", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("messages", "attachment_refs")
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { parseUserAttachments } from "../web/static/js/attachments.js";
|
||||
|
||||
const chatJs = readFileSync("web/static/js/chat.js", "utf8");
|
||||
|
||||
test("user attachment protocol lines are separated from visible message text", () => {
|
||||
const parsed = parseUserAttachments(
|
||||
"请比较这张图\n\n[用户上传的参考图] 项目/显微照片.png\n[用户上传的文件] 项目/数据表.xlsx",
|
||||
);
|
||||
assert.equal(parsed.content, "请比较这张图");
|
||||
assert.deepEqual(parsed.attachments, [
|
||||
{ path: "项目/显微照片.png", kind: "image" },
|
||||
{ path: "项目/数据表.xlsx", kind: "file" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("attachment-only messages have an empty visible body", () => {
|
||||
const parsed = parseUserAttachments("[用户上传的参考图] 项目/照片 01.png");
|
||||
assert.equal(parsed.content, "");
|
||||
assert.deepEqual(parsed.attachments, [{ path: "项目/照片 01.png", kind: "image" }]);
|
||||
});
|
||||
|
||||
test("ordinary bracketed text remains visible", () => {
|
||||
const content = "[备注] 这不是附件\n继续处理";
|
||||
assert.deepEqual(parseUserAttachments(content), { content, attachments: [] });
|
||||
});
|
||||
|
||||
test("new sends use structured attachments and history keeps a legacy fallback", () => {
|
||||
assert.match(chatJs, /attachments:\s*pendingAttachments\.map/);
|
||||
assert.match(chatJs, /Array\.isArray\(m\.attachment_refs\)/);
|
||||
assert.match(chatJs, /structuredAttachments === null/);
|
||||
assert.doesNotMatch(chatJs, /content = content \? `\$\{content\}\\n\\n\$\{lines\}`/);
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from core.artifacts import ArtifactPathError
|
||||
from core.attachments import content_for_model, normalize_attachment_refs
|
||||
|
||||
|
||||
class AttachmentRefTests(unittest.TestCase):
|
||||
def test_normalizes_user_relative_path_to_task_relative_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
wd = root / "实验分析"
|
||||
wd.mkdir()
|
||||
image = wd / "显微照片.png"
|
||||
image.write_bytes(b"png")
|
||||
refs = normalize_attachment_refs(
|
||||
[{"path": "实验分析/显微照片.png", "kind": "image"}],
|
||||
working_dir=wd,
|
||||
user_root=root,
|
||||
)
|
||||
self.assertEqual(refs[0]["path"], "显微照片.png")
|
||||
self.assertEqual(refs[0]["kind"], "image")
|
||||
self.assertEqual(refs[0]["size_bytes"], 3)
|
||||
self.assertEqual(refs[0]["scope"], "working_dir")
|
||||
|
||||
def test_rejects_attachment_outside_working_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
wd = root / "任务"
|
||||
wd.mkdir()
|
||||
(root / "outside.txt").write_text("x", encoding="utf-8")
|
||||
with self.assertRaises(ArtifactPathError):
|
||||
normalize_attachment_refs(
|
||||
[{"path": "../outside.txt", "kind": "file"}],
|
||||
working_dir=wd,
|
||||
user_root=root,
|
||||
)
|
||||
|
||||
def test_builds_provider_content_without_changing_stored_text(self):
|
||||
refs = [{"path": "figures/a.png", "kind": "image"}]
|
||||
self.assertEqual(
|
||||
content_for_model("请分析", refs, "实验分析"),
|
||||
"请分析\n\n[用户上传的参考图] 实验分析/figures/a.png",
|
||||
)
|
||||
self.assertEqual(
|
||||
content_for_model("", refs, "实验分析"),
|
||||
"[用户上传的参考图] 实验分析/figures/a.png",
|
||||
)
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
"""look_at_image 的问题收敛与兼容兜底测试(不碰网络和数据库)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from core.ark_client import ArkConfig
|
||||
from tools.look_at_image import LookAtImageTool, _DEFAULT_QUESTION
|
||||
|
||||
|
||||
class LookAtImageQuestionTests(unittest.TestCase):
|
||||
def _tool(self) -> LookAtImageTool:
|
||||
return LookAtImageTool(
|
||||
ark_cfg=ArkConfig(api_key="test", base_url="https://example.invalid", raw={}),
|
||||
vision_variant_cfg={
|
||||
"model_id": "vision-test",
|
||||
"request_timeout_s": 1,
|
||||
"timeout_retries": 0,
|
||||
},
|
||||
variant_key="test",
|
||||
working_dir=Path("."),
|
||||
task_id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
base_dir=Path("."),
|
||||
user_root=Path("."),
|
||||
)
|
||||
|
||||
def _execute_and_question(self, question=None) -> str:
|
||||
captured = {}
|
||||
|
||||
def fake_chat(_cfg, _endpoint, body, **_kwargs):
|
||||
captured["question"] = body["messages"][0]["content"][0]["text"]
|
||||
return {
|
||||
"choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
|
||||
"usage": {},
|
||||
}, ""
|
||||
|
||||
with mock.patch(
|
||||
"tools.look_at_image.load_image_as_data_url",
|
||||
return_value=("data:image/png;base64,AA==", "image.png", ""),
|
||||
), mock.patch("tools.look_at_image.ark_chat_with_retry", side_effect=fake_chat), \
|
||||
mock.patch("tools.look_at_image.record_usage_safe", return_value=0):
|
||||
self._tool().execute("image.png", question=question)
|
||||
return captured["question"]
|
||||
|
||||
def test_specific_question_is_forwarded_unchanged(self):
|
||||
question = "只读出仪表盘当前数值,不要描述其他内容。"
|
||||
self.assertEqual(self._execute_and_question(question), question)
|
||||
|
||||
def test_missing_question_uses_concise_compatibility_fallback(self):
|
||||
self.assertEqual(self._execute_and_question(), _DEFAULT_QUESTION)
|
||||
self.assertIn("简洁", _DEFAULT_QUESTION)
|
||||
self.assertIn("不要主动全文 OCR", _DEFAULT_QUESTION)
|
||||
self.assertNotIn("完整描述画面内容", _DEFAULT_QUESTION)
|
||||
self.assertNotIn("把其中的数据、坐标轴、图例", _DEFAULT_QUESTION)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from web.schemas import MessageRequest
|
||||
|
||||
|
||||
class MessageAttachmentSchemaTests(unittest.TestCase):
|
||||
def test_omitted_attachments_remains_distinguishable_for_legacy_clients(self):
|
||||
body = MessageRequest(content="旧客户端正文")
|
||||
self.assertEqual(body.attachments, [])
|
||||
self.assertNotIn("attachments", body.model_fields_set)
|
||||
|
||||
def test_explicit_structured_attachments_are_accepted(self):
|
||||
body = MessageRequest(
|
||||
content="分析图片",
|
||||
attachments=[{"path": "任务/a.png", "kind": "image"}],
|
||||
)
|
||||
self.assertIn("attachments", body.model_fields_set)
|
||||
self.assertEqual(body.attachments[0].path, "任务/a.png")
|
||||
|
||||
def test_attachment_count_is_limited(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
MessageRequest(
|
||||
content="太多",
|
||||
attachments=[{"path": f"任务/{i}.txt"} for i in range(11)],
|
||||
)
|
||||
|
|
@ -38,7 +38,6 @@ class ClaimRunTests(unittest.TestCase):
|
|||
with patch.object(run_lifecycle, "session_scope", fake_scope):
|
||||
claim = run_lifecycle.claim_run_with_message(
|
||||
tid, uid, "持久化消息", prepare=prepare,
|
||||
attachment_refs=[{"path": "a.png", "kind": "image"}],
|
||||
)
|
||||
|
||||
self.assertEqual(claim.metadata, {"marker": "ok"})
|
||||
|
|
@ -49,7 +48,6 @@ class ClaimRunTests(unittest.TestCase):
|
|||
added.payload,
|
||||
{"role": "user", "content": "持久化消息"},
|
||||
)
|
||||
self.assertEqual(added.attachment_refs, [{"path": "a.png", "kind": "image"}])
|
||||
update_stmt = session.execute.call_args_list[-1].args[0]
|
||||
params = update_stmt.compile().params
|
||||
self.assertEqual(params["run_status"], "running")
|
||||
|
|
|
|||
|
|
@ -123,8 +123,7 @@ class StaticVendorTests(unittest.TestCase):
|
|||
self.assertIn(".composer-tool .composer-label", css)
|
||||
self.assertIn('aria-label="添加附件"', html)
|
||||
self.assertIn("await ensureTaskFromDraft();", frontend)
|
||||
self.assertIn("attachments: pendingAttachments.map", frontend)
|
||||
self.assertIn("parseUserAttachments", frontend)
|
||||
self.assertIn('"[用户上传的文件]"', frontend)
|
||||
self.assertNotIn("发送第一条消息创建对话后即可添加附件", frontend)
|
||||
self.assertIn('aria-label="语音输入"', html)
|
||||
self.assertIn('aria-label="润色提示词"', html)
|
||||
|
|
|
|||
|
|
@ -52,11 +52,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertNotIn("NodeToken", form)
|
||||
self.assertNotIn("Clipboard", form)
|
||||
self.assertIn("清除本机身份并重新注册", form)
|
||||
self.assertIn("管理后台删除或禁用云端旧节点", form)
|
||||
self.assertIn("管理员禁用云端旧节点", form)
|
||||
|
||||
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
|
||||
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
|
||||
self.assertIn("ClientSize = new Size(880, 720)", form)
|
||||
self.assertIn("ClientSize = new Size(760, 690)", form)
|
||||
self.assertIn("FormBorderStyle.Sizable", form)
|
||||
self.assertIn("AutoScaleMode.Dpi", form)
|
||||
self.assertIn("AutoScroll = true", form)
|
||||
|
|
@ -65,12 +65,6 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn("成功注册一次后立即失效", form)
|
||||
self.assertIn("CreateCard", form)
|
||||
self.assertIn("注册并连接", form)
|
||||
self.assertIn("ContentWidth = 800", form)
|
||||
self.assertIn("节点能力", form)
|
||||
self.assertIn("Origin 绘图", form)
|
||||
self.assertIn("当前 MVP 内置声明该协议,不需要手工配置", form)
|
||||
self.assertIn("registrationCard.Visible = !registered", form)
|
||||
self.assertIn("registeredActionsCard.Visible = registered", form)
|
||||
|
||||
def test_startup_task_is_login_scoped_and_runs_the_fixed_node_executable(self) -> None:
|
||||
script = (ROOT / "install-startup.ps1").read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -21,11 +21,12 @@ from .output import compact_tool_output
|
|||
from .image_ref import load_image_as_data_url
|
||||
from .media_common import ark_chat_with_retry, extract_chat_answer, record_usage_safe
|
||||
|
||||
# question 理应由主模型按用户任务具体填写;这里仅作旧调用兼容兜底。
|
||||
# 默认只给简洁概览,避免无条件触发「描述 + 全文 OCR + 图表解析」导致长输出和高延迟。
|
||||
# 不带 question 时的默认提问:全覆盖(描述 + OCR + 图表读数),让模型一次把图里能用的信息都吐出来
|
||||
_DEFAULT_QUESTION = (
|
||||
"请简洁说明这张图片的主要内容,只提供理解图片所必需的信息。"
|
||||
"不要主动全文 OCR、逐项枚举或展开图表数据;无法确定时明确说明。"
|
||||
"请仔细看这张图并回答:"
|
||||
"1) 完整描述画面内容(主体/场景/关键元素);"
|
||||
"2) 如果图中有任何文字,逐字准确 OCR 出来,尽量保留原排版与换行;"
|
||||
"3) 如果是图表/表格/示意图,把其中的数据、坐标轴、图例、结构关系读出来。"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -35,10 +36,10 @@ class LookAtImageTool(Tool):
|
|||
"Read/understand an image using Doubao Seed 2.0 Lite vision (the main model is text-only). "
|
||||
"Use to OCR text, describe a picture, read charts/tables/diagrams, or identify objects in an "
|
||||
"image the user uploaded (look for a `[用户上传的参考图] <path>` line in their message) or that "
|
||||
"was generated/saved in the task. Pass the image path and a task-specific `question`; "
|
||||
"only request full-image OCR when the user explicitly needs it. SLOW (tens of seconds per "
|
||||
"call) — only call when you genuinely need the image's actual content to proceed, and ask "
|
||||
"everything you need in ONE call; never re-read the same image. "
|
||||
"was generated/saved in the task. Pass the image path; optionally a specific question "
|
||||
"(default: describe + OCR everything). SLOW (tens of seconds per call) — only call when you "
|
||||
"genuinely need the image's actual content to proceed, and ask everything you need in ONE "
|
||||
"call (bundle all questions into `question`); never re-read the same image. "
|
||||
"Returns the model's textual reading of the image."
|
||||
)
|
||||
parameters = {
|
||||
|
|
@ -54,9 +55,8 @@ class LookAtImageTool(Tool):
|
|||
"question": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"本次任务需要从图里知道什么(强烈建议填写)。如「读出表格中的抗压强度数据」"
|
||||
"「图中仪表读数是多少」「把这页文字完整 OCR 出来」。只问完成用户任务所需内容;"
|
||||
"仅当用户明确要求整图识别时才要求全文 OCR。不传则仅返回简洁画面概览。"
|
||||
"想从图里知道什么(可选)。如「这张图里的表格数据是多少」「图中仪表读数」"
|
||||
"「把这页文字 OCR 出来」。不传则默认全面描述 + OCR 全部文字。"
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,7 +100,6 @@ def register_message_routes(app, *, require_user) -> None:
|
|||
Message.idx, Message.payload, Message.tokens_in,
|
||||
Message.tokens_out, Message.model_profile, Message.created_at,
|
||||
Message.artifact_refs,
|
||||
Message.attachment_refs,
|
||||
)
|
||||
if limit is None:
|
||||
# 旧行为:升序全量
|
||||
|
|
@ -149,7 +148,6 @@ def register_message_routes(app, *, require_user) -> None:
|
|||
"model_profile": r.model_profile, # 0006:assistant 行非空,标产生该 msg 的模型
|
||||
"created_at": iso(r.created_at),
|
||||
"artifact_refs": r.artifact_refs,
|
||||
"attachment_refs": r.attachment_refs,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
|
@ -207,7 +205,7 @@ def register_message_routes(app, *, require_user) -> None:
|
|||
headers={"Retry-After": "3"},
|
||||
)
|
||||
content = (body.content or "").strip()
|
||||
if not content and not body.attachments:
|
||||
if not content:
|
||||
raise HTTPException(400, "empty content")
|
||||
# 快捷指令展开(与渠道入口共用 core/shortcuts.py):整条精确命中触发词 → 换成完整
|
||||
# 指令。在起 run 之前、落库之前展开,模型看到的就是完整指令(不进上下文、不问模型)。
|
||||
|
|
@ -221,31 +219,6 @@ def register_message_routes(app, *, require_user) -> None:
|
|||
# running 后才发现参数非法,会留下一个实际上没有 worker 的假活跃任务。
|
||||
image_variant = resolve_image_model(body.image_model, user_id=user_id)
|
||||
video_variant = resolve_video_model(body.video_model, user_id=user_id)
|
||||
# Omitted means a legacy client; explicit [] means a new message has no attachments.
|
||||
attachments_supplied = "attachments" in body.model_fields_set
|
||||
attachment_refs: Optional[list[dict]] = [] if attachments_supplied else None
|
||||
if body.attachments:
|
||||
from core.artifacts import ArtifactPathError
|
||||
from core.attachments import normalize_attachment_refs
|
||||
from core.paths import from_db_path
|
||||
root = load_user_root(user_id)
|
||||
with session_scope() as s:
|
||||
task_working_dir = s.execute(
|
||||
select(Task.working_dir).where(
|
||||
Task.task_id == tid, Task.user_id == user_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not task_working_dir:
|
||||
raise HTTPException(404, f"task not found: {tid}")
|
||||
working_dir = from_db_path(task_working_dir).resolve()
|
||||
try:
|
||||
attachment_refs = normalize_attachment_refs(
|
||||
body.attachments,
|
||||
working_dir=working_dir,
|
||||
user_root=root,
|
||||
)
|
||||
except ArtifactPathError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
def _prepare_claim(s, task):
|
||||
values: dict = {}
|
||||
# 档位门控:存量 task 的模型已不在用户档位内(如管理员下调了档位)→ 本次起
|
||||
|
|
@ -275,7 +248,6 @@ def register_message_routes(app, *, require_user) -> None:
|
|||
try:
|
||||
claim = claim_run_with_message(
|
||||
tid, user_id, content, prepare=_prepare_claim,
|
||||
attachment_refs=attachment_refs,
|
||||
)
|
||||
except RunTaskNotFound:
|
||||
raise HTTPException(404, f"task not found: {tid}")
|
||||
|
|
@ -304,7 +276,7 @@ def register_message_routes(app, *, require_user) -> None:
|
|||
should_auto_title = claim.metadata["should_auto_title"]
|
||||
if should_auto_title:
|
||||
from core.task_title import is_attachment_only_message
|
||||
should_auto_title = bool(content) and not is_attachment_only_message(content)
|
||||
should_auto_title = not is_attachment_only_message(content)
|
||||
# 快速入口只在首条消息时 pending=true。辅助标题与主 run 并行,不阻塞
|
||||
# TTFT/SSE;纯附件消息保留 pending,等下一条自然语言再命名,避免标题暴露
|
||||
# `[用户上传的文件]` 等内部标记。generate_task_title 内再次查闸并用条件 UPDATE
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ def claim_run_with_message(
|
|||
user_message: str,
|
||||
*,
|
||||
prepare: Optional[PrepareClaim] = None,
|
||||
attachment_refs: Optional[list[dict[str, Any]]] = None,
|
||||
) -> RunClaim:
|
||||
"""在 task 行锁下原子提交 user 消息和 `run_status=running`。
|
||||
|
||||
|
|
@ -83,7 +82,6 @@ def claim_run_with_message(
|
|||
task_id=task_id,
|
||||
idx=int(next_idx),
|
||||
payload={"role": "user", "content": user_message},
|
||||
attachment_refs=attachment_refs,
|
||||
))
|
||||
values = {
|
||||
"run_status": "running",
|
||||
|
|
|
|||
|
|
@ -33,15 +33,8 @@ class SchedulePatchRequest(BaseModel):
|
|||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class MessageAttachmentRequest(BaseModel):
|
||||
path: str
|
||||
kind: str = "file"
|
||||
label: str = ""
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
content: str
|
||||
attachments: list[MessageAttachmentRequest] = Field(default_factory=list, max_length=10)
|
||||
# 该条消息触发的生图 / 生视频模型 variant key(config/media/doubao.yaml image/video 段)。
|
||||
# 空 → 对应 tool 走 yaml 第一个 variant;非空 → 本次 run 装配指定 variant。
|
||||
# 仅作用于本 run,不入 DB,UI 下拉的选择跟在消息 POST body 上。
|
||||
|
|
|
|||
|
|
@ -1132,14 +1132,6 @@
|
|||
width: auto; height: auto; cursor: zoom-in;
|
||||
}
|
||||
.art-media-video { flex: 0 1 720px; width: min(100%, 720px); }
|
||||
/* 用户发出的图片先以紧凑缩略图确认内容,下面仍保留统一附件 chip。 */
|
||||
.user-attachments { margin-top: 6px; }
|
||||
.user-attachments .artifact-bar { margin-top: 4px; }
|
||||
.user-attachments .art-media-image { border-radius: 8px; }
|
||||
.user-attachments .art-media-image img {
|
||||
width: auto; height: auto; max-width: min(240px, 100%); max-height: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.art-media video {
|
||||
display: block; width: 100%; height: auto; max-height: 405px; background: #000;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
// 新消息使用结构化 attachment_refs;这里只解析旧客户端与存量消息的正文标记。
|
||||
const USER_ATTACHMENT_RE = /^\[(用户上传的参考图|用户上传的文件)\]\s+(.+?)\s*$/;
|
||||
|
||||
export function parseUserAttachments(content) {
|
||||
const attachments = [];
|
||||
const bodyLines = [];
|
||||
for (const line of String(content || "").split("\n")) {
|
||||
const match = line.match(USER_ATTACHMENT_RE);
|
||||
if (!match) {
|
||||
bodyLines.push(line);
|
||||
continue;
|
||||
}
|
||||
attachments.push({
|
||||
path: match[2],
|
||||
kind: match[1] === "用户上传的参考图" ? "image" : "file",
|
||||
});
|
||||
}
|
||||
return { content: bodyLines.join("\n").trim(), attachments };
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ import {
|
|||
} from "./preview.js";
|
||||
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
|
||||
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
|
||||
import { parseUserAttachments } from "./attachments.js";
|
||||
import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js";
|
||||
import { refreshProcs, decorateBgprocCard, hasRunningProc, killTaskProcs } from "./procs.js";
|
||||
|
||||
|
|
@ -1709,13 +1708,8 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
|||
hasVisibleAssistantContent = true;
|
||||
}
|
||||
}
|
||||
const structuredAttachments = role === "user" && Array.isArray(m.attachment_refs)
|
||||
? m.attachment_refs : null;
|
||||
const userParts = role === "user" && structuredAttachments === null
|
||||
? parseUserAttachments(p.content) : null;
|
||||
const visibleContent = userParts ? userParts.content : p.content;
|
||||
if (typeof visibleContent === "string" && visibleContent) {
|
||||
html += `<div class="body">${renderMd(visibleContent)}</div>`;
|
||||
if (typeof p.content === "string" && p.content) {
|
||||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||||
if (role === "assistant") hasVisibleAssistantContent = true;
|
||||
// assistant 正文里 echo 的 <wd>/... 路径**永远**展示(绕开 seenRels)。图片/视频
|
||||
// 已可能在产物工具结果中内联,仍用 chip 防重复;HTML 通常由 write/shell 产出,
|
||||
|
|
@ -1725,12 +1719,6 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
|||
html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), "html", state.taskId || "", true);
|
||||
}
|
||||
}
|
||||
const visibleAttachments = structuredAttachments || (userParts && userParts.attachments) || [];
|
||||
if (visibleAttachments.length) {
|
||||
html += renderUserAttachmentsHtml(
|
||||
visibleAttachments, state.taskId || "", structuredAttachments === null,
|
||||
);
|
||||
}
|
||||
if (role === "assistant" && Array.isArray(m.artifact_refs)) {
|
||||
html += renderArtifactBarHtml(m.artifact_refs, true, state.taskId || "");
|
||||
if (m.artifact_refs.length) hasVisibleAssistantContent = true;
|
||||
|
|
@ -1796,7 +1784,6 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
|||
wrap.appendChild(sb);
|
||||
_msgScrollObserver.observe(sb);
|
||||
}
|
||||
|
||||
if (stickBottom) wrap.scrollTop = wrap.scrollHeight;
|
||||
setTaskProgress(state.taskId, currentProgressSteps);
|
||||
upgradeMediaArtifacts(wrap);
|
||||
|
|
@ -1804,18 +1791,6 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
|
|||
renderLiveRunIfVisible();
|
||||
}
|
||||
|
||||
// 用户附件与助手产物语义不同:每个附件都保留可辨认、可点击的 chip;图片另外在
|
||||
// chip 上方给缩略图,兼顾快速确认内容和统一的预览/下载入口。
|
||||
function renderUserAttachmentsHtml(attachments, taskId = "", legacy = false) {
|
||||
if (!attachments || !attachments.length) return "";
|
||||
const images = attachments.filter((item) => item.kind === "image");
|
||||
const refs = attachments.map((item) => ({ path: item.path, label: item.label || "" }));
|
||||
return `<div class="user-attachments">`
|
||||
+ (images.length ? renderArtifactBarHtml(images, true, taskId, legacy) : "")
|
||||
+ renderArtifactBarHtml(refs, false, taskId, legacy)
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
// ───── send + SSE ─────
|
||||
// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边);
|
||||
// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。
|
||||
|
|
@ -2686,9 +2661,16 @@ async function sendMessage(overrideText) {
|
|||
$("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
|
||||
return;
|
||||
}
|
||||
// 附件路径按类型注入正文:图片走视觉/改图工具,其他文件走文档读取、音频转写等工具。
|
||||
const pendingAttachments = fromInput ? takePendingAttachments() : [];
|
||||
const attachmentOnly = !content && pendingAttachments.length > 0;
|
||||
if (!content && !pendingAttachments.length) return;
|
||||
if (pendingAttachments.length) {
|
||||
const lines = pendingAttachments.map(({ rel, kind }) =>
|
||||
`${kind === "image" ? "[用户上传的参考图]" : "[用户上传的文件]"} ${rel}`
|
||||
).join("\n");
|
||||
content = content ? `${content}\n\n${lines}` : lines;
|
||||
}
|
||||
if (!content) return;
|
||||
setActionMode("cancelling"); // 临时锁住,等 events_url 拿到再切 streaming
|
||||
$("chat-hint").textContent = "发送中…";
|
||||
const taskId = state.taskId;
|
||||
|
|
@ -2698,13 +2680,8 @@ async function sendMessage(overrideText) {
|
|||
const wrap = $("chat-stream");
|
||||
const userCard = document.createElement("div");
|
||||
userCard.className = "msg user";
|
||||
userCard.innerHTML = `<div class="role">我</div>`
|
||||
+ (content ? `<div class="body">${escapeHtml(content)}</div>` : "")
|
||||
+ renderUserAttachmentsHtml(
|
||||
pendingAttachments.map(({ rel, kind }) => ({ path: rel, kind })), taskId, true,
|
||||
);
|
||||
userCard.innerHTML = `<div class="role">我</div><div class="body">${escapeHtml(content)}</div>`;
|
||||
wrap.appendChild(userCard);
|
||||
upgradeMediaArtifacts(userCard);
|
||||
|
||||
// assistant 流式占位卡
|
||||
const asstCard = document.createElement("div");
|
||||
|
|
@ -2715,10 +2692,6 @@ async function sendMessage(overrideText) {
|
|||
|
||||
const r = await postMessageWithRetry(taskId, {
|
||||
content,
|
||||
attachments: pendingAttachments.map(({ rel, kind }) => ({
|
||||
path: rel,
|
||||
kind,
|
||||
})),
|
||||
image_model: state.imageModel || "",
|
||||
video_model: state.videoModel || "",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,30 +2,25 @@ namespace Zcbot.WindowsNode;
|
|||
|
||||
internal sealed class ConfigurationForm : Form
|
||||
{
|
||||
private const int ContentWidth = 800;
|
||||
|
||||
private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765");
|
||||
private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant());
|
||||
private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true);
|
||||
private readonly Button register = CreateButton("注册并连接", 128, primary: true);
|
||||
private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220);
|
||||
private readonly Button register = CreateButton("注册并连接", primary: true);
|
||||
private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册");
|
||||
private readonly CheckBox startAtLogin = new()
|
||||
{
|
||||
Text = "登录 Windows 后自动启动节点",
|
||||
AutoSize = true,
|
||||
Margin = new Padding(0, 4, 0, 8),
|
||||
AutoSize = false,
|
||||
Height = 32,
|
||||
Dock = DockStyle.Top,
|
||||
};
|
||||
private readonly Label state = new()
|
||||
{
|
||||
AutoSize = true,
|
||||
Font = new Font("Microsoft YaHei UI", 13, FontStyle.Bold),
|
||||
Margin = new Padding(0, 2, 0, 5),
|
||||
Font = new Font("Microsoft YaHei UI", 12, FontStyle.Bold),
|
||||
};
|
||||
private readonly Label detail = CreateBodyLabel();
|
||||
private readonly Label identity = CreateBodyLabel();
|
||||
private readonly Label capabilitySummary = CreateBodyLabel();
|
||||
private readonly TableLayoutPanel registrationCard;
|
||||
private readonly TableLayoutPanel registeredActionsCard;
|
||||
private readonly Label detail = new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105) };
|
||||
private readonly Label identity = new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105) };
|
||||
private bool changingStartup;
|
||||
|
||||
internal event Func<EnrollOptions, Task>? RegisterRequested;
|
||||
|
|
@ -35,38 +30,24 @@ internal sealed class ConfigurationForm : Form
|
|||
{
|
||||
Text = "zcbot Windows Node";
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
ClientSize = new Size(880, 720);
|
||||
MinimumSize = new Size(850, 680);
|
||||
ClientSize = new Size(760, 690);
|
||||
MinimumSize = new Size(720, 640);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Font = new Font("Microsoft YaHei UI", 9);
|
||||
FormBorderStyle = FormBorderStyle.Sizable;
|
||||
BackColor = Color.FromArgb(241, 245, 249);
|
||||
|
||||
var shell = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoScroll = true,
|
||||
ColumnCount = 3,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(0, 22, 0, 22),
|
||||
BackColor = BackColor,
|
||||
};
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, ContentWidth));
|
||||
shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
||||
Controls.Add(shell);
|
||||
BackColor = Color.FromArgb(245, 247, 250);
|
||||
|
||||
var page = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
Dock = DockStyle.Top,
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(28, 24, 28, 24),
|
||||
AutoScroll = true,
|
||||
ColumnCount = 1,
|
||||
RowCount = 6,
|
||||
RowCount = 7,
|
||||
BackColor = BackColor,
|
||||
};
|
||||
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
shell.Controls.Add(page, 1, 0);
|
||||
Controls.Add(page);
|
||||
|
||||
var heading = new TableLayoutPanel
|
||||
{
|
||||
|
|
@ -85,65 +66,41 @@ internal sealed class ConfigurationForm : Form
|
|||
heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点"));
|
||||
page.Controls.Add(heading);
|
||||
|
||||
var overview = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Top,
|
||||
ColumnCount = 2,
|
||||
Margin = new Padding(0, 0, 0, 14),
|
||||
};
|
||||
overview.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
||||
overview.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
|
||||
|
||||
var statusCard = CreateCard();
|
||||
statusCard.Margin = new Padding(0, 0, 7, 0);
|
||||
var statusCard = CreateCard(148);
|
||||
statusCard.Controls.Add(CreateSectionTitle("节点状态"));
|
||||
statusCard.Controls.Add(state);
|
||||
statusCard.Controls.Add(detail);
|
||||
statusCard.Controls.Add(identity);
|
||||
overview.Controls.Add(statusCard, 0, 0);
|
||||
page.Controls.Add(statusCard);
|
||||
|
||||
var capabilityCard = CreateCard();
|
||||
capabilityCard.Margin = new Padding(7, 0, 0, 0);
|
||||
capabilityCard.Controls.Add(CreateSectionTitle("节点能力"));
|
||||
capabilityCard.Controls.Add(CreateCapabilityBadge("Origin 绘图", "origin.plot@v1"));
|
||||
capabilityCard.Controls.Add(capabilitySummary);
|
||||
capabilityCard.Controls.Add(CreateHint(
|
||||
"当前 MVP 内置声明该协议,不需要手工配置;它不代表已完成 Origin 安装检测。"));
|
||||
overview.Controls.Add(capabilityCard, 1, 0);
|
||||
page.Controls.Add(overview);
|
||||
var configCard = CreateCard(360);
|
||||
configCard.Controls.Add(CreateSectionTitle("注册配置"));
|
||||
configCard.Controls.Add(CreateHint(
|
||||
"首次连接需要管理员在 zcbot 管理端生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。"));
|
||||
AddField(configCard, "zcbot 服务地址", server, "本机测试通常使用 http://127.0.0.1:8765");
|
||||
AddField(configCard, "节点名称", nodeName, "用于管理员识别这台 Windows 计算节点");
|
||||
AddField(configCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;它不是日常登录密码,也不会被长期保存");
|
||||
var actions = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
Dock = DockStyle.Fill,
|
||||
Margin = new Padding(0, 12, 0, 0),
|
||||
};
|
||||
actions.Controls.Add(register);
|
||||
actions.Controls.Add(resetIdentity);
|
||||
configCard.Controls.Add(actions);
|
||||
page.Controls.Add(configCard);
|
||||
|
||||
registrationCard = CreateCard();
|
||||
registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
|
||||
registrationCard.Controls.Add(CreateHint(
|
||||
"从 zcbot 管理后台生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。"));
|
||||
AddField(registrationCard, "zcbot 服务地址", server, "云端填写站点根地址;本机测试使用 http://127.0.0.1:8765");
|
||||
AddField(registrationCard, "节点名称", nodeName, "必须与注册码限定的节点名称完全一致");
|
||||
AddField(registrationCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;仅用于首次注册,不会长期保存");
|
||||
var registerActions = CreateActions();
|
||||
registerActions.Controls.Add(register);
|
||||
registrationCard.Controls.Add(registerActions);
|
||||
page.Controls.Add(registrationCard);
|
||||
|
||||
registeredActionsCard = CreateCard();
|
||||
registeredActionsCard.Controls.Add(CreateSectionTitle("节点身份"));
|
||||
registeredActionsCard.Controls.Add(CreateHint(
|
||||
"服务地址、节点名称和能力在首次注册时确定。需要修改时,请先在管理后台删除旧节点,再清除本机身份并重新注册。"));
|
||||
var identityActions = CreateActions();
|
||||
identityActions.Controls.Add(resetIdentity);
|
||||
registeredActionsCard.Controls.Add(identityActions);
|
||||
page.Controls.Add(registeredActionsCard);
|
||||
|
||||
var runtimeCard = CreateCard();
|
||||
var runtimeCard = CreateCard(104);
|
||||
runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
|
||||
runtimeCard.Controls.Add(startAtLogin);
|
||||
runtimeCard.Controls.Add(CreateHint(
|
||||
"关闭此窗口后,节点仍在系统托盘运行。右键托盘图标可以立即重连或退出。"));
|
||||
runtimeCard.Controls.Add(CreateHint("关闭配置窗口后,节点仍会在系统托盘运行。右键托盘图标可重连或退出。"));
|
||||
page.Controls.Add(runtimeCard);
|
||||
|
||||
var securityNote = CreateHint(
|
||||
"安全说明:Node Token 由 Windows DPAPI 加密保存,不在界面显示,也不会写入日志。");
|
||||
securityNote.Margin = new Padding(4, 8, 4, 0);
|
||||
var securityNote = CreateHint("安全说明:Node Token 由 Windows DPAPI 加密保存,不在界面中显示,也不会写入日志。");
|
||||
securityNote.Margin = new Padding(4, 14, 4, 0);
|
||||
page.Controls.Add(securityNote);
|
||||
|
||||
register.Click += async (_, _) => await RegisterAsync();
|
||||
|
|
@ -181,19 +138,16 @@ internal sealed class ConfigurationForm : Form
|
|||
};
|
||||
detail.Text = $"{status.Message} {status.ChangedAt:HH:mm:ss}";
|
||||
identity.Text = config is null
|
||||
? "尚未建立节点身份"
|
||||
? $"配置文件:{NodePaths.ForCurrentMachine().ConfigPath}"
|
||||
: $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}";
|
||||
capabilitySummary.Text = config is null
|
||||
? "注册后启用"
|
||||
: $"已声明 {config.Capabilities.Count} 项能力";
|
||||
|
||||
var registered = config is not null;
|
||||
registrationCard.Visible = !registered;
|
||||
registeredActionsCard.Visible = registered;
|
||||
register.Enabled = !registered && status.State != NodeState.Connecting;
|
||||
if (registered)
|
||||
server.Enabled = config is null;
|
||||
nodeName.Enabled = config is null;
|
||||
enrollmentCode.Enabled = config is null;
|
||||
register.Enabled = config is null && status.State != NodeState.Connecting;
|
||||
resetIdentity.Visible = config is not null;
|
||||
if (config is not null)
|
||||
{
|
||||
server.Text = config!.ServerUrl.AbsoluteUri.TrimEnd('/');
|
||||
server.Text = config.ServerUrl.AbsoluteUri.TrimEnd('/');
|
||||
nodeName.Text = config.NodeName;
|
||||
enrollmentCode.Clear();
|
||||
}
|
||||
|
|
@ -202,7 +156,7 @@ internal sealed class ConfigurationForm : Form
|
|||
private void ResetIdentity()
|
||||
{
|
||||
var answer = MessageBox.Show(
|
||||
"这会删除本机加密身份,随后需要使用新注册码重新注册。请先在管理后台删除或禁用云端旧节点。是否继续?",
|
||||
"这会删除本机加密身份,随后需要使用新注册码重新注册。请同时让管理员禁用云端旧节点。是否继续?",
|
||||
"清除本机节点身份", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
if (answer == DialogResult.Yes)
|
||||
|
|
@ -257,15 +211,16 @@ internal sealed class ConfigurationForm : Form
|
|||
}
|
||||
}
|
||||
|
||||
private static TableLayoutPanel CreateCard()
|
||||
private static TableLayoutPanel CreateCard(int minimumHeight)
|
||||
{
|
||||
var card = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
MinimumSize = new Size(0, minimumHeight),
|
||||
Dock = DockStyle.Top,
|
||||
ColumnCount = 1,
|
||||
Padding = new Padding(20, 17, 20, 17),
|
||||
Padding = new Padding(22, 18, 22, 18),
|
||||
Margin = new Padding(0, 0, 0, 14),
|
||||
BackColor = Color.White,
|
||||
};
|
||||
|
|
@ -282,15 +237,6 @@ internal sealed class ConfigurationForm : Form
|
|||
Margin = new Padding(0, 0, 0, 8),
|
||||
};
|
||||
|
||||
private static Label CreateBodyLabel() => new()
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
ForeColor = Color.FromArgb(71, 85, 105),
|
||||
MaximumSize = new Size(340, 0),
|
||||
Margin = new Padding(0, 2, 0, 5),
|
||||
};
|
||||
|
||||
private static Label CreateHint(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
|
|
@ -300,44 +246,23 @@ internal sealed class ConfigurationForm : Form
|
|||
Margin = new Padding(0, 2, 0, 4),
|
||||
};
|
||||
|
||||
private static Panel CreateCapabilityBadge(string title, string protocol)
|
||||
{
|
||||
var badge = new Panel
|
||||
{
|
||||
AutoSize = false,
|
||||
Height = 54,
|
||||
Dock = DockStyle.Top,
|
||||
BackColor = Color.FromArgb(239, 246, 255),
|
||||
Margin = new Padding(0, 2, 0, 8),
|
||||
Padding = new Padding(12, 7, 12, 7),
|
||||
};
|
||||
badge.Controls.Add(new Label
|
||||
{
|
||||
Text = $"{title}\n协议:{protocol}",
|
||||
AutoSize = true,
|
||||
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(29, 78, 216),
|
||||
});
|
||||
return badge;
|
||||
}
|
||||
|
||||
private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new()
|
||||
{
|
||||
Text = text,
|
||||
UseSystemPasswordChar = usePassword,
|
||||
AutoSize = false,
|
||||
Height = 34,
|
||||
Height = 36,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
Font = new Font("Segoe UI", 10),
|
||||
};
|
||||
|
||||
private static Button CreateButton(string text, int width, bool primary = false)
|
||||
private static Button CreateButton(string text, bool primary = false)
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
Text = text,
|
||||
AutoSize = false,
|
||||
Size = new Size(width, 36),
|
||||
Size = new Size(primary ? 128 : 220, 38),
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White,
|
||||
ForeColor = primary ? Color.White : Color.FromArgb(51, 65, 85),
|
||||
|
|
@ -349,15 +274,6 @@ internal sealed class ConfigurationForm : Form
|
|||
return button;
|
||||
}
|
||||
|
||||
private static FlowLayoutPanel CreateActions() => new()
|
||||
{
|
||||
AutoSize = true,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
Dock = DockStyle.Fill,
|
||||
Margin = new Padding(0, 10, 0, 0),
|
||||
};
|
||||
|
||||
private static void AddField(
|
||||
TableLayoutPanel layout, string label, Control control, string hint)
|
||||
{
|
||||
|
|
@ -367,7 +283,7 @@ internal sealed class ConfigurationForm : Form
|
|||
AutoSize = true,
|
||||
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(51, 65, 85),
|
||||
Margin = new Padding(0, 8, 0, 4),
|
||||
Margin = new Padding(0, 9, 0, 4),
|
||||
});
|
||||
control.Dock = DockStyle.Top;
|
||||
control.Margin = new Padding(0, 0, 0, 2);
|
||||
|
|
|
|||
Loading…
Reference in New Issue