api+ui(media): 顶栏生图模型下拉(消息级,不入 DB) + 中间产物图片/视频内联展示
- 新加 GET /v1/image_models(扫 config/media/doubao.yaml image 段)
- POST /v1/tasks/{id}/messages body 加可选 image_model 字段,_run_agent_bg
透传到 build_agent(image_variant=...);agent_builder 据此装配 SeedreamTool
variant,不命中 yaml 静默回 fallback,空 → 沿用第一个
- dev SPA:顶栏「模型」旁加「生图」下拉(默认锁第一个 variant,per-session
state 不持久),sendMessage 携 image_model 一起发
- 中间产物 chip 按文件类型分支:图片/视频走 .art-media 异步 fetch blob →
填 <img>/<video controls>(Bearer header 不允许 <img src=> 直 URL);
图片点击仍弹模态放大,视频用浏览器原生 controls;openFilePreview 加
_showVideo + .mp4/.webm/.mov/.mkv/.m4v 进 _EXT_GROUPS;_mediaArtifactCache
按 rel 复用,切 task 时 revoke
- DESIGN 不动(无架构 / schema 变化);PROGRESS / RUN 同步
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a3acb97079
commit
8c9e0d0d3a
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
||||
|
||||
最后更新:2026-05-20(LLM 走 streaming + 前端打字机 + 发送/停止单按钮 + cancel 秒退)
|
||||
最后更新:2026-05-20(dev SPA 加生图模型下拉 + 中间产物图片/视频内联展示 + `/v1/image_models` 端点 + `POST .../messages` 加 `image_model` 字段)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
|
||||
### 2026-05-20
|
||||
|
||||
- **dev SPA 顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:用户要 ① 项目栏右侧的模型选区加一个生图模型选择(目前只 seedream,默认选上),② 中间产物若是图片/视频直接在对话区展示(不只点击预览)。**生图选择范式判断**:不入 task 列(seedream/seedance 是 tool 范畴,non-chat,task 切粒度太粗;且现在仅一个 variant,加 DB 列纯负债)→ 走**消息级**:UI 下拉的选择跟 `POST /v1/tasks/{id}/messages` body 的 `image_model` 字段一起发,`_run_agent_bg` → `build_agent(image_variant=...)` → seedream tool 装配时按 key 挑 yaml 里 `image` 段的对应 variant_cfg;不入 DB,本 run 内多次 tool call 共用,下条消息可重选。**后端新接口** `GET /v1/image_models`(scan `config/media/doubao.yaml` image 段返 `{variant, display_name, model_id, price_cny_per_image, is_default}` 列表;不要求 `ARK_API_KEY` 已设 — UI 只展示元数据,真调时 `ArkConfig.load()` 那侧再过 key 检查),`_resolve_image_model(variant)` 校验存在性(空串 → 透传走 fallback,非空 → 必须命中 yaml,否则 400)。`agent_builder.build_agent` 新参 `image_variant: str = ""`:非空且命中 → 用它装 SeedreamTool;不命中(yaml 改动后旧选择 stale)静默回 fallback;空 → 沿用"取第一个 variant"。**前端**:`state.imageModels` + `state.imageModel`(per-session,不持久);`loadModels()` 同时拉 `/v1/image_models` 并锁第一个为默认;`renderImageModelDropdown()` 在 `renderModelDropdown` 旁画一个 `生图 [▾]`(yaml 无 variant 时不画);`onChangeImageModel` 纯前端 state 更新无 PATCH;`sendMessage` 把 `state.imageModel` 跟在 POST body 上发出去。**内联媒体**:`_EXT_GROUPS` 加 `video: {mp4,webm,mov,mkv,m4v}` 集合;`renderArtifactBarHtml` 按 `_categorize(rel)` 分支:image/video → 占位 `<span class="art-media[-image|-video]" data-rel="...">`,其他 → 沿用 `.art-chip`;新 `upgradeMediaArtifacts(root)` DOM walk 把占位异步换 `<img>`/`<video controls>`,经 `_fetchMediaBlobUrl(rel)`(Bearer header 不能直 `<img src=>` → fetch 拿 blob 转 `URL.createObjectURL`)+ `_mediaArtifactCache` 同 rel 复用;模态 `openFilePreview` 加 `_showVideo`(`<video controls autoplay>`);chip / `.art-media-image` 点击 → 弹模态放大,视频走原生 controls 不拦截(点击=暂停/播放,全屏走浏览器按钮,弹模态反而打断播放)。**缓存生命周期**:cache 走 `selectTask` 切换时 `_flushMediaArtifactCache` 撤销 blob URL;同 task 流式 / 历史回放复用,/clear 不清(FS 文件保留,rel 仍有效);logout 走 `location.reload()` 全清。**没动**:DESIGN(无架构/schema 变化)、Task / TaskState / TaskCreateRequest / TaskPatchRequest schema(早期一稿曾加 `Task.image_model` 列 + 0008 migration,用户复盘后判定 task 粒度不合适撤回 — 测试期 DB downgrade 不留 0008,模型/字段也清干净)、`record_image_usage`(model_profile 仍走 `doubao.<variant_key>`,自然跟着 build_agent 选的 variant)、artifact chip 抽取(其他类型仍走 chip + openFilePreview)。**Tradeoff**:① 不入 DB → 浏览器刷新会回到 yaml 第一个 variant 默认值,但用户中途切换的"上次选了什么"丢了 — 改用 localStorage 即可,先简单版;② 内联图片对每条带产物的消息都触发 fetch,blob URL 累积在 cache 里直到切 task 才回收,长会话场景 + 数十张图理论上内存吃几百 MB(普通 PNG 1-3MB),后续若问题再加 IntersectionObserver 懒加载。
|
||||
|
||||
- **LLM 调用切 streaming(cancel 秒退 + 前端打字机)+ 发送/停止合并单按钮**:用户反馈"点停止要等很久"+"发送/停止可以合并"。**问题 1 根因**:`litellm.completion(...)` 是同步阻塞,Python 没标准办法外部线程打断同步 IO;`broker.is_cancelled` 只在 `core/loop.py:run()` 每轮 LLM 前 + tool_calls 之间 poll,所以 cancel 必须等当前整轮 generation 跑完才生效(deepseek v4 + thinking + 长输出几十秒)。**修法**:切 `litellm.completion(stream=True)`,`core/llm.py` 加 `chat_stream()` generator(`stream_options={"include_usage": True}` 让最后 chunk 带 usage;`_build_kwargs` 抽出来给 chat/chat_stream 共用,免重复参数装配);`core/loop.py` 主循环改 `_stream_llm()` 流式迭代,chunk 间 poll cancel,命中 `break` + generator finally `stream.close()` 关底层 httpx 连接;chunks 攒齐用 `litellm.stream_chunk_builder(chunks, messages=...)` 拼回完整 response(自动处理 tool_call name/arguments 跨 chunk 拼接)给 tool_calls 解析 + usage 记账。**cancel 语义对齐**:stream 中途 cancel → 已收 chunk 丢弃不入库不记账(下次 resume 上下文干净);stream 完结后 tool_calls 之间 cancel → 沿用原 `_fill_cancelled_tool_results` 补 cancelled tool message。**前端打字机免费 bonus**:`dev.html:1500-1510` 早就备好接 `text` 事件的 `delta` 字段(rAF 节流 + nearBottom 不抢滚动 + 流中不跑 highlight),但后端原来发的是 `{"type":"text","content":"<整段>"}` 字段名对不上 → 前端永远 match 不到。新逻辑在 `_stream_llm` 里 chunk 到达即 `_emit({"type":"text","delta":...})`,前端自然激活打字机。loop.py 主流程末尾不再 emit 整段 text(content 已通过 delta 流过)。**问题 2 UI**:`web/static/dev.html` 把 `#chat-send`(发送)+ `#chat-cancel`(停止)合并为单 `#chat-action`,新 helper `setActionMode(mode)`(idle="发送" primary 红实心 / streaming="停止" danger 红边 / cancelling="停止中…" disabled);form submit + `chatAction()` 根据 `state.streaming` 分派 sendMessage / cancelCurrentTask;streaming 期间 Enter 不触发停止(textarea 编辑下一条草稿,误触发风险高)。**Smoke 验证**:① 18 chunks 流式 + 文本拼回 ✓ ② tool_call 49 chunks 跨片拼回 `{"a":7,"b":5}` 完整 ✓ ③ 提前 break + close 仅 0.7s(模拟"写 500 字散文中途 cancel")✓。**Tradeoffs**:① streaming 重试只在连接建立阶段(没拿到第一个 chunk 前)生效,中途断流不续 — 实务罕见;② timeout 行为从"整段 timeout"变"chunk 间隔 timeout",新模型接入要测 thinking 不吐 reasoning chunk 的极端情况;③ litellm `stream_chunk_builder` + `stream_options.include_usage` 在 deepseek/doubao/glm/openai 标准协议都正常,新接非主流 provider 时验证。**没动**:probe.py(仍用同步 `chat()`,离线探测不需要 cancel)、CLI 路径(probe 走 chat 不受影响)、broker / SSE 帧格式 / `record_chat_usage` 入参 / DB schema / messages 入库时机(拼回 response 跟非流式等价)。**文档**:`DESIGN.md` §3.1 翻转 tradeoff 表「LLM 同步 call 不可中断」→「LLM 调用走 streaming」+ §7 API 表 cancel 描述改 chunk-level 延迟;`RUN.md` cancel 接口 + 故障兜底表对应行同步;`web/app.py` 两条 docstring 同步。
|
||||
|
||||
- **dev SPA seedream tool 透明性 banner(model/size/cost/elapsed)**:用户问"实际生图用哪个模型 / 价格区别 / 前端要不要给用户选";seedream 现仅一个 variant(5.0),无选择空间 — 但用户**能看到**用了什么模型、花了多少是基本透明度。最小路径:SeedreamTool 返回串首行改成 `[seedream] model=... · size=... · cost=¥... · elapsed=...s` 结构化 banner(用 `·` 分隔 + `key=value` 严格格式,正则 parse);dev SPA 新加 `extractMediaBanner(toolName, resultText)` helper,流式 `tool_result` 与历史回放 `role==="tool"` 两路都在 `<details>` 的 `<summary>` 旁挂一行徽章(`.tool-banner .kv`,model 红字 / cost 暗红 / 其他灰色);model 文本去 `doubao-` 前缀与 `-260128` 日期后缀截短显示 `seedream-5-0`;**折叠态可见,无需展开**。LLM 看到的完整文本不丢(banner 同条第一行就是字符串)。**没动**:tool schema(不加 model 参数 — 单 variant 没意义,等 seedance 二期 pro/fast 真有价差时统一加 task 级下拉 + `tasks.image_model_profile` 列设计)、artifact chip 抽取(figures/*.png 现有逻辑无变化)、DB / 后端。**Tradeoff**:走文本 banner 而非从 .meta.json fetch — 简单 + 即时,代价是 tool 返回串格式成"前端约定"(改格式要同步前端 regex)。
|
||||
|
|
|
|||
6
RUN.md
6
RUN.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||||
|
||||
最后更新:2026-05-20(加 `POST /v1/tasks/{id}/clear` 清空对话路由)
|
||||
最后更新:2026-05-20(加 `GET /v1/image_models` 列图像 variant + `POST .../messages` body 新增 `image_model` 可选字段)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
|||
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
|
||||
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
|
||||
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
|
||||
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);UI 应 disable send 直到 SSE `done` | 必填 |
|
||||
| `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 级,基本秒退 | 必填 |
|
||||
| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_cny` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 |
|
||||
|
|
@ -135,6 +135,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
|||
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
|
||||
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
|
||||
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
|
||||
| `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 |
|
||||
| `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 |
|
||||
|
||||
**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}` → `text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}` → `done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
|
||||
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ def build_agent(
|
|||
description: str = "",
|
||||
name: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
image_variant: str = "",
|
||||
) -> Tuple[AgentLoop, Session, str, TaskState, Path]:
|
||||
"""返回 (agent, session, task_id_str, task_state, working_dir_path)。
|
||||
|
||||
|
|
@ -346,17 +347,28 @@ def build_agent(
|
|||
|
||||
# 媒体生成 tool(豆包 seedream / 后续 seedance):仅当 ARK_API_KEY 设了才挂 ——
|
||||
# 没 key 的用户无感知,不至于看到 schema 里突然多个永远报错的工具。
|
||||
# image_variant 由 caller 传(web 入口随消息 POST 带);空 → 取 yaml 第一个 variant
|
||||
# (fallback,沿用原行为)。本次 run 装的 SeedreamTool 锁定该 variant,本 run 内的
|
||||
# 多次 tool call 全用同一个;下一条消息可以重选。
|
||||
ark_cfg = ArkConfig.load()
|
||||
if ark_cfg is not None:
|
||||
image_cfg = (ark_cfg.raw.get("image") or {})
|
||||
# 取第一个 image variant 作 seedream 主入口(目前只有 seedream_5)
|
||||
chosen_key, chosen_cfg = "", None
|
||||
if image_variant:
|
||||
v = image_cfg.get(image_variant)
|
||||
if isinstance(v, dict):
|
||||
chosen_key, chosen_cfg = image_variant, v
|
||||
# 不认的 variant 静默退到 fallback —— web 入口已校验过;留兜底防 yaml 改动
|
||||
if chosen_cfg is None:
|
||||
for variant_key, variant_cfg in image_cfg.items():
|
||||
if not isinstance(variant_cfg, dict):
|
||||
continue
|
||||
if isinstance(variant_cfg, dict):
|
||||
chosen_key, chosen_cfg = variant_key, variant_cfg
|
||||
break
|
||||
if chosen_cfg is not None:
|
||||
seedream_tool = SeedreamTool(
|
||||
ark_cfg=ark_cfg,
|
||||
image_variant_cfg=variant_cfg,
|
||||
variant_key=variant_key,
|
||||
image_variant_cfg=chosen_cfg,
|
||||
variant_key=chosen_key,
|
||||
working_dir=working_dir_path,
|
||||
task_id=task_id,
|
||||
user_id=uid,
|
||||
|
|
@ -364,7 +376,6 @@ def build_agent(
|
|||
user_root=ur_path,
|
||||
)
|
||||
tools[seedream_tool.name] = seedream_tool
|
||||
break # 一个 image tool 入口足够;variants 暂不并存
|
||||
|
||||
sink = ConsoleEventSink(console, token_counter=lambda: llm.token_counter.total) if console else None
|
||||
agent = AgentLoop(llm, tools, session, caps, user_id=uid, sink=sink)
|
||||
|
|
|
|||
75
web/app.py
75
web/app.py
|
|
@ -246,7 +246,9 @@ def _validate_transfer(
|
|||
|
||||
# ─────────────────── BG run + SSE 帧格式 ───────────────────
|
||||
|
||||
def _run_agent_bg(task_id: UUID, user_id: UUID, user_message: str) -> None:
|
||||
def _run_agent_bg(
|
||||
task_id: UUID, user_id: UUID, user_message: str, image_variant: str = "",
|
||||
) -> None:
|
||||
"""工作线程:`build_agent(resume=True)` → 装 WebEventSink + cancel_check → `agent.run` → 写 tasks.run_status。
|
||||
|
||||
sink 通过 broker.emit 桥事件回 asyncio loop;agent.run 是 sync,所以在 to_thread 跑。
|
||||
|
|
@ -254,12 +256,16 @@ def _run_agent_bg(task_id: UUID, user_id: UUID, user_message: str) -> None:
|
|||
cancel_check 桥 broker.is_cancelled,loop 在 stream chunk 间 + 工具调用之间 poll;
|
||||
cancel 延迟 ~ 单 chunk 间隔(100ms 级)。
|
||||
`ok / cancelled` 收尾直接回 `idle`(不留持久标记);只有 error 是持久终态。
|
||||
|
||||
image_variant:本 run 用哪个图像 variant 装 seedream(空 → yaml 第一个)。
|
||||
随消息 POST 传进来,不入 DB —— UI 下拉的选择就跟在这一条消息上生效。
|
||||
"""
|
||||
from core.agent_builder import build_agent, sync_task_tokens
|
||||
try:
|
||||
broker.emit(task_id, {"type": "run_start"})
|
||||
agent, session, sid, task_state, task_dir = build_agent(
|
||||
session_id=str(task_id), resume=True, user_id=user_id,
|
||||
image_variant=image_variant,
|
||||
)
|
||||
agent.sink = WebEventSink(broker, task_id)
|
||||
agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid)
|
||||
|
|
@ -315,6 +321,42 @@ def _resolve_model_profile(profile: str) -> tuple[str, str]:
|
|||
return name, caps.model_id
|
||||
|
||||
|
||||
def _list_image_variants() -> list[tuple[str, dict]]:
|
||||
"""扫 config/media/doubao.yaml image 段 → [(variant_key, variant_cfg), ...]。
|
||||
|
||||
yaml 不存在或 image 段空 / 仅注释 → 返 []。不要求 ARK_API_KEY 已设 —— 仅纯
|
||||
元数据列举,UI 拉这个画下拉。真正调用 seedream 时 agent_builder 那边再过
|
||||
`ArkConfig.load()`(没 key → tool 不注册)。
|
||||
"""
|
||||
from core.paths import ROOT
|
||||
import yaml as _yaml
|
||||
|
||||
p = ROOT / "config" / "media" / "doubao.yaml"
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = _yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return []
|
||||
image_cfg = data.get("image") or {}
|
||||
return [(k, v) for k, v in image_cfg.items() if isinstance(v, dict)]
|
||||
|
||||
|
||||
def _resolve_image_model(variant: str) -> str:
|
||||
"""校验 image_model variant key。
|
||||
|
||||
传空 → 返空(agent_builder fallback 到第一个 variant);传非空 → 必须存在
|
||||
于 config/media/doubao.yaml image 段,否则 400。
|
||||
"""
|
||||
name = (variant or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
variants = {k for k, _ in _list_image_variants()}
|
||||
if name not in variants:
|
||||
raise HTTPException(400, f"invalid image_model {name!r}; available: {sorted(variants)}")
|
||||
return name
|
||||
|
||||
|
||||
# ────────────────────── Pydantic 请求体 ──────────────────────
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
|
|
@ -335,6 +377,11 @@ class TaskPatchRequest(BaseModel):
|
|||
|
||||
class MessageRequest(BaseModel):
|
||||
content: str
|
||||
# 该条消息触发的生图模型 variant key(config/media/doubao.yaml image 段)。
|
||||
# 空 → seedream tool 走 yaml 第一个 variant(目前 seedream_5);非空 → 本次 run
|
||||
# 用此 variant 装配 SeedreamTool。仅作用于本 run,不入 DB,UI 下拉的选择跟在
|
||||
# 消息 POST body 上。
|
||||
image_model: str = ""
|
||||
|
||||
|
||||
class FileDeleteRequest(BaseModel):
|
||||
|
|
@ -468,6 +515,26 @@ def create_app() -> FastAPI:
|
|||
})
|
||||
return {"models": out}
|
||||
|
||||
@app.get("/v1/image_models", tags=["misc"])
|
||||
def list_image_models(user_id: UUID = Depends(require_user)):
|
||||
"""图像生成模型清单(扫 config/media/doubao.yaml image 段)。
|
||||
|
||||
前端顶栏第二个下拉拉这个;空列表 → 没配 image variant,UI 隐藏下拉。
|
||||
`is_default` 标第一个 variant(=agent_builder fallback 目标)。开发期不缓存,
|
||||
改 YAML 加新 variant(如 seedance)立即生效。
|
||||
"""
|
||||
variants = _list_image_variants()
|
||||
out: list[dict] = []
|
||||
for i, (key, cfg) in enumerate(variants):
|
||||
out.append({
|
||||
"variant": key,
|
||||
"display_name": cfg.get("display_name") or key,
|
||||
"model_id": cfg.get("model_id") or "",
|
||||
"price_cny_per_image": cfg.get("price_cny_per_image"),
|
||||
"is_default": i == 0,
|
||||
})
|
||||
return {"models": out}
|
||||
|
||||
# ───────────── Auth ─────────────
|
||||
|
||||
@app.post("/v1/auth/login", tags=["auth"])
|
||||
|
|
@ -880,9 +947,13 @@ def create_app() -> FastAPI:
|
|||
run_status="running", run_error=None,
|
||||
)
|
||||
)
|
||||
# image_model 在 POST 时校验,避免 BG 线程里抛在 sink 之外难追;空串透传不查 yaml。
|
||||
image_variant = _resolve_image_model(body.image_model)
|
||||
broker.start(tid) # 清上一轮 done 标记,新订阅者才能看到流式
|
||||
# commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop)
|
||||
asyncio.create_task(asyncio.to_thread(_run_agent_bg, tid, user_id, content))
|
||||
asyncio.create_task(asyncio.to_thread(
|
||||
_run_agent_bg, tid, user_id, content, image_variant,
|
||||
))
|
||||
return {"events_url": f"/v1/tasks/{tid}/events"}
|
||||
|
||||
# ───────────── Cancel current run ─────────────
|
||||
|
|
|
|||
|
|
@ -357,6 +357,24 @@
|
|||
.art-chip:hover {
|
||||
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
|
||||
}
|
||||
/* 内联图片/视频:产物 chip 替代,fetch 完直接展示 */
|
||||
.art-media {
|
||||
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
|
||||
background: #fff; display: inline-block; line-height: 0;
|
||||
}
|
||||
.art-media .art-media-loading, .art-media .art-media-error {
|
||||
display: inline-block; padding: 6px 10px; font-size: 11px;
|
||||
color: var(--muted); line-height: 1.4; font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.art-media .art-media-error { color: #b34a4a; }
|
||||
.art-media img {
|
||||
display: block; max-width: 360px; max-height: 280px;
|
||||
width: auto; height: auto; cursor: zoom-in;
|
||||
}
|
||||
.art-media video {
|
||||
display: block; max-width: 480px; max-height: 320px;
|
||||
width: auto; height: auto; background: #000;
|
||||
}
|
||||
|
||||
#chat-form {
|
||||
border-top: 1px solid var(--border); padding: 10px; background: #fafafa;
|
||||
|
|
@ -498,6 +516,9 @@
|
|||
max-width: 100%; max-height: 100%; object-fit: contain;
|
||||
display: block; margin: 0 auto;
|
||||
}
|
||||
#file-preview-modal .body video.preview-video {
|
||||
max-width: 100%; max-height: 100%; display: block; margin: 0 auto; outline: none;
|
||||
}
|
||||
#file-preview-modal .body iframe.preview-frame {
|
||||
width: 100%; height: 100%; border: 0;
|
||||
}
|
||||
|
|
@ -759,6 +780,11 @@ const state = {
|
|||
taskHasMore: true,
|
||||
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
|
||||
models: [],
|
||||
// 图像生成模型清单(GET /v1/image_models;ARK_API_KEY 未设也会拿到 yaml 元数据)
|
||||
imageModels: [],
|
||||
// 当前选中的图像生成 variant key(per-session,不入 DB);默认 = imageModels[0].variant
|
||||
// (=yaml 第一个 = agent_builder fallback)。下次 send 消息时随 POST body 带给 backend。
|
||||
imageModel: "",
|
||||
};
|
||||
|
||||
// ───── helpers ─────
|
||||
|
|
@ -1034,6 +1060,18 @@ async function loadModels() {
|
|||
} catch (e) {
|
||||
state.models = []; // 静默兜底:无模型清单时下拉不显示,不挡正常流程
|
||||
}
|
||||
try {
|
||||
const data = await api("GET", "/v1/image_models");
|
||||
state.imageModels = data.models || [];
|
||||
// 默认锁定第一个(=agent_builder fallback);用户后续切换就会更新
|
||||
if (!state.imageModel) {
|
||||
const def = state.imageModels.find(m => m.is_default) || state.imageModels[0];
|
||||
state.imageModel = def ? def.variant : "";
|
||||
}
|
||||
} catch (e) {
|
||||
state.imageModels = [];
|
||||
state.imageModel = "";
|
||||
}
|
||||
}
|
||||
|
||||
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
|
||||
|
|
@ -1202,6 +1240,9 @@ $("filter-wd").addEventListener("focus", ensureFoldersLoaded);
|
|||
// ───── select task ─────
|
||||
async function selectTask(tid) {
|
||||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||||
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
|
||||
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
|
||||
if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache();
|
||||
state.taskId = tid;
|
||||
document.querySelectorAll(".task-row").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tid === tid);
|
||||
|
|
@ -1237,10 +1278,13 @@ function renderChatMeta() {
|
|||
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
|
||||
<span class="spacer"></span>
|
||||
${renderModelDropdown(t)}
|
||||
${renderImageModelDropdown()}
|
||||
<span class="muted small">${t.n_messages || 0} 条 · ${t.tokens || 0} tok</span>
|
||||
`;
|
||||
const sel = $("chat-model-sel");
|
||||
if (sel) sel.onchange = onChangeModel;
|
||||
const imgSel = $("chat-image-model-sel");
|
||||
if (imgSel) imgSel.onchange = onChangeImageModel;
|
||||
const active = t.status === "active";
|
||||
$("chat-form").style.display = active ? "flex" : "none";
|
||||
$("btn-done").disabled = !active;
|
||||
|
|
@ -1262,6 +1306,24 @@ function renderModelDropdown(t) {
|
|||
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">模型 <select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
|
||||
}
|
||||
|
||||
function renderImageModelDropdown() {
|
||||
// imageModels 为空(yaml 无 image variant)→ 不画下拉。注意不依赖 ARK_API_KEY 是否设了
|
||||
// —— 这里只是展示元数据,真正调用时 backend 那边没 key 自然 tool 不挂(用户不会
|
||||
// 在没 key 的环境点出图,prompt 里 seedream 工具压根不在 schema)。
|
||||
if (!state.imageModels || state.imageModels.length === 0) return "";
|
||||
const cur = state.imageModel || "";
|
||||
const opts = state.imageModels.map(m =>
|
||||
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||||
).join("");
|
||||
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">生图 <select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
|
||||
}
|
||||
|
||||
function onChangeImageModel(ev) {
|
||||
// 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发
|
||||
state.imageModel = ev.target.value || "";
|
||||
$("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
|
||||
}
|
||||
|
||||
async function onChangeModel(ev) {
|
||||
const sel = ev.target;
|
||||
const newProfile = sel.value;
|
||||
|
|
@ -1355,6 +1417,7 @@ function renderMessages(msgs) {
|
|||
wrap.appendChild(card);
|
||||
}
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
upgradeMediaArtifacts(wrap);
|
||||
}
|
||||
|
||||
// ───── send + SSE ─────
|
||||
|
|
@ -1394,12 +1457,21 @@ $("chat-input").addEventListener("keydown", (e) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 对话流里 artifact chip 的点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")
|
||||
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
|
||||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
||||
// 弹个 modal 反而打断播放,不如交给浏览器。
|
||||
$("chat-stream").addEventListener("click", (e) => {
|
||||
const chip = e.target.closest && e.target.closest(".art-chip");
|
||||
if (!chip) return;
|
||||
if (chip) {
|
||||
const rel = chip.dataset.rel;
|
||||
if (rel) openFilePreview(rel);
|
||||
return;
|
||||
}
|
||||
const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
|
||||
if (inlineImg) {
|
||||
const rel = inlineImg.dataset.rel;
|
||||
if (rel) openFilePreview(rel);
|
||||
}
|
||||
});
|
||||
|
||||
async function sendMessage() {
|
||||
|
|
@ -1424,7 +1496,10 @@ async function sendMessage() {
|
|||
wrap.appendChild(asstCard);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
|
||||
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
|
||||
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, {
|
||||
content,
|
||||
image_model: state.imageModel || "",
|
||||
});
|
||||
$("chat-input").value = "";
|
||||
state.streaming = true;
|
||||
setActionMode("streaming");
|
||||
|
|
@ -1543,7 +1618,10 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
asstCard.appendChild(det);
|
||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||
const barHtml = renderArtifactBarHtml(extractArtifactRels(argsStr, wd));
|
||||
if (barHtml) asstCard.insertAdjacentHTML("beforeend", barHtml);
|
||||
if (barHtml) {
|
||||
asstCard.insertAdjacentHTML("beforeend", barHtml);
|
||||
upgradeMediaArtifacts(asstCard);
|
||||
}
|
||||
} else if (t === "tool_result") {
|
||||
const txt = (ev.data && ev.data.result) || "";
|
||||
const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
|
||||
|
|
@ -1555,7 +1633,10 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
asstCard.appendChild(det);
|
||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||
const barHtml = renderArtifactBarHtml(extractArtifactRels(txtStr, wd));
|
||||
if (barHtml) asstCard.insertAdjacentHTML("beforeend", barHtml);
|
||||
if (barHtml) {
|
||||
asstCard.insertAdjacentHTML("beforeend", barHtml);
|
||||
upgradeMediaArtifacts(asstCard);
|
||||
}
|
||||
scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
|
||||
} else if (t === "cancelled") {
|
||||
const badge = document.createElement("div");
|
||||
|
|
@ -2058,11 +2139,71 @@ function extractArtifactRels(text, workingDir) {
|
|||
|
||||
function renderArtifactBarHtml(rels) {
|
||||
if (!rels || !rels.length) return "";
|
||||
const chips = rels.map((rel) => {
|
||||
const items = rels.map((rel) => {
|
||||
const name = rel.split("/").pop() || rel;
|
||||
const cat = _categorize(rel);
|
||||
if (cat === "image" || cat === "video") {
|
||||
// 占位元素;插入 DOM 后 upgradeMediaArtifacts 异步 fetch blob → 填 <img>/<video>。
|
||||
// 不在这里发请求避免 string-build 阶段失控的并发;upgrade 走 DOM walk 一次。
|
||||
return `<span class="art-media art-media-${cat}" data-rel="${escapeHtml(rel)}" data-cat="${cat}" title="${escapeHtml(rel)}"><span class="art-media-loading">${escapeHtml(name)} 加载中…</span></span>`;
|
||||
}
|
||||
return `<button type="button" class="art-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览(可下载)">${escapeHtml(name)}</button>`;
|
||||
}).join("");
|
||||
return `<div class="artifact-bar">${chips}</div>`;
|
||||
return `<div class="artifact-bar">${items}</div>`;
|
||||
}
|
||||
|
||||
// rel → Promise<blob-url>。auth 是 Bearer header,不能直接 <img src=>,只能 fetch
|
||||
// 拿 blob 再转 URL。同 rel 在同会话内复用,免重复拉。task 切换 / logout 时
|
||||
// _flushMediaArtifactCache 清掉旧 URL 防泄漏。
|
||||
const _mediaArtifactCache = new Map();
|
||||
|
||||
function _fetchMediaBlobUrl(rel) {
|
||||
if (_mediaArtifactCache.has(rel)) return _mediaArtifactCache.get(rel);
|
||||
const p = fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||||
headers: { "Authorization": "Bearer " + state.token },
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
const blob = await r.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
_mediaArtifactCache.set(rel, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function _flushMediaArtifactCache() {
|
||||
for (const p of _mediaArtifactCache.values()) {
|
||||
p.then((u) => URL.revokeObjectURL(u)).catch(() => {});
|
||||
}
|
||||
_mediaArtifactCache.clear();
|
||||
}
|
||||
|
||||
// DOM walk:把所有 .art-media[data-rel] 占位换成 <img> / <video>。
|
||||
// renderMessages / SSE 插入完后调一次;重复调用幂等(已 upgrade 过的 set data-upgraded 跳过)。
|
||||
function upgradeMediaArtifacts(root) {
|
||||
const nodes = (root || document).querySelectorAll(".art-media[data-rel]:not([data-upgraded])");
|
||||
nodes.forEach((node) => {
|
||||
node.dataset.upgraded = "1";
|
||||
const rel = node.dataset.rel;
|
||||
const cat = node.dataset.cat;
|
||||
_fetchMediaBlobUrl(rel).then((url) => {
|
||||
node.innerHTML = "";
|
||||
if (cat === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = rel.split("/").pop() || rel;
|
||||
img.loading = "lazy"; // 浏览器懒解码(已在 viewport 内立即可见,远处暂不解)
|
||||
node.appendChild(img);
|
||||
} else if (cat === "video") {
|
||||
const v = document.createElement("video");
|
||||
v.src = url;
|
||||
v.controls = true;
|
||||
v.preload = "metadata";
|
||||
node.appendChild(v);
|
||||
}
|
||||
}).catch((e) => {
|
||||
node.innerHTML = `<span class="art-media-error">${escapeHtml(rel.split("/").pop() || rel)} 加载失败:${escapeHtml(e.message || String(e))}</span>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(rel) {
|
||||
|
|
@ -2111,6 +2252,7 @@ function _flushBlobUrls() {
|
|||
|
||||
const _EXT_GROUPS = {
|
||||
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
|
||||
video: new Set(["mp4","webm","mov","mkv","m4v"]),
|
||||
pdf: new Set(["pdf"]),
|
||||
md: new Set(["md","markdown"]),
|
||||
text: new Set([
|
||||
|
|
@ -2164,6 +2306,7 @@ async function openFilePreview(rel) {
|
|||
return;
|
||||
}
|
||||
if (cat === "image") _showImage(blob);
|
||||
else if (cat === "video") _showVideo(blob);
|
||||
else if (cat === "pdf") _showPdf(blob);
|
||||
else if (cat === "docx") await _showDocx(blob);
|
||||
else if (cat === "xlsx") await _showXlsx(blob);
|
||||
|
|
@ -2185,6 +2328,19 @@ function _showImage(blob) {
|
|||
body.appendChild(img);
|
||||
}
|
||||
|
||||
function _showVideo(blob) {
|
||||
const url = _trackBlobUrl(blob);
|
||||
const body = $("fp-body");
|
||||
body.className = "body center";
|
||||
body.innerHTML = "";
|
||||
const v = document.createElement("video");
|
||||
v.className = "preview-video";
|
||||
v.src = url;
|
||||
v.controls = true;
|
||||
v.autoplay = true;
|
||||
body.appendChild(v);
|
||||
}
|
||||
|
||||
function _showPdf(blob) {
|
||||
const url = _trackBlobUrl(blob, "application/pdf");
|
||||
const body = $("fp-body");
|
||||
|
|
|
|||
Loading…
Reference in New Issue