api+ui(chat): 删输入框冗余上传按钮 + 加润色按钮 — POST /v1/tasks/{id}/optimize_prompt 走 task 当前模型同步润色,usage_events 新 kind=prompt_optimize 单独记账不污染主对话累计;前端 execCommand insertText 接 textarea 原生 undo 栈,Ctrl+Z 一次回到原文
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
febe04a569
commit
3c2e25d912
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
||||
|
||||
最后更新:2026-05-20(dev SPA 加生图模型下拉 + 中间产物图片/视频内联展示 + `/v1/image_models` 端点 + `POST .../messages` 加 `image_model` 字段)
|
||||
最后更新:2026-05-20(dev SPA 输入区移除"⬆ 上传"按钮 + 加"✨ 润色"按钮 + 后端 `POST /v1/tasks/{id}/optimize_prompt` 辅助 LLM 调用,usage_events 走新 kind="prompt_optimize")
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
|
||||
### 2026-05-20
|
||||
|
||||
- **dev SPA 输入区移除"⬆ 上传"按钮 + 加"✨ 润色"按钮(后端 `POST /v1/tasks/{id}/optimize_prompt`)**:用户反馈 ① 输入框下的"⬆ 上传"按钮意义不大(右侧文件面板已有同功能 `btn-upload`,完全重复) ② 加一个"润色"按钮 — 用户输简短草稿,根据当前对话模型 + 已选生图模型扩写/优化并替换原文本。**前端**:`web/static/dev.html` 删 `#chat-upload` 按钮 + 它的 onclick(line 1764);同位置加 `<button id="chat-optimize">✨ 润色</button>`(disabled 默认,textarea `input` 事件经 `syncOptimizeBtn` 联动启/禁,要求有内容 + 有 `state.taskId`)。点击 → `state.optimizing=true` + 按钮 disabled + 文案 "润色中…" + chat-hint "润色中…";POST 成功 → `textarea.focus(); textarea.select(); document.execCommand("insertText", false, optimized)` 把"全选 + 插入"作为一个 undo 单元接入 textarea **原生** undo 栈,Ctrl+Z 一次回到原文(execCommand 已 deprecated 但 textarea undo 集成所有浏览器仍支持);失败时贴 chat-hint 不动文本;execCommand 兜底失败(罕见旧浏览器)直接赋值 + 提示"本浏览器不支持撤销"。`sendMessage` 清空 textarea 后 + `renderChatMeta` 切 task 后各补一次 `syncOptimizeBtn`(value="" 不触发 input 事件,得手动 sync)。**后端**:`web/app.py` 加 `OptimizePromptRequest{text, image_model}` + `POST /v1/tasks/{tid}/optimize_prompt` handler — 校验 task 归属 user、text 非空 + ≤4000 字、用 `task.model_profile`(空 fallback default)装配 `LLM`,同步调 `chat()`(非 stream,短文本 3-5s 接受),meta-prompt 包含"当前对话模型 display_name + 选中 image variant 元数据(display_name / default_size / 适合画面细节描述)+ 4 条输出规则(只输出文本/保留原语言/补全模糊点不无中生有/长度合理)+ 用户草稿"。**计费决策**:写一行 `usage_events` **新 kind="prompt_optimize"**(`message_id=NULL`,`task_id` 仍挂当前 task,units 含 `tokens_in/out + usd_to_cny + image_model_hint`),**不** 调 `sync_task_tokens` → tasks 表的 `tokens_prompt/completion/cost_cny`(顶栏"N 条 · M tok"展示用)不被污染。心智:顶栏数字=主对话累计(用户感知),usage_events 全表 SUM=API 账单对账(润色也在内),按 `kind` GROUP BY 可单独评估"这个按钮值不值";单次成本 < ¥0.01(DeepSeek Pro)/ < ¥0.001(Flash)。**不与主对话 run 互斥**:它不写 messages 无 idx 竞争,允许 streaming 期间并行润色下一条草稿。**没动**:`record_chat_usage`(它 hardcode kind="chat",新增 kind 直接 `s.add(UsageEvent(...))` 内联,免给现有函数加参数污染)、DB schema(usage_events.kind 是 Text 列,加新值无需 migration)、image_model 处理(沿用 `_list_image_variants` 元数据)、视频模型(yaml 还没 video 段,等接 seedance 时同模式扩 video_model 字段)、DESIGN(无架构/schema 变化)。**Tradeoff**:① 不做用户偏好持久化(润色风格/温度);② 不接 streaming(短文本完整文本替换比逐字打印体验好,且 textarea undo 集成 execCommand 也只能一次性插入);③ 不接 prompt history(用户失败/退回的原文本可 Ctrl+Z 拿回,不需要服务端记历史)。**未浏览器实测**:HTML/JS 改动小但 textarea undo 行为需真浏览器跑一次确认 Ctrl+Z 链路;后端 `create_app()` import + route 注册通过(`/v1/tasks/{task_id}/optimize_prompt` 已在 routes 表)。
|
||||
|
||||
- **dev SPA 中间产物 chip / inline 图去重 + CLAUDE.md 新增"实施前先对方案"段**:用户报"工具结果里挂了一张图,后面 assistant 正文又挂了一张同图,有点重复"。根因:`renderArtifactBarHtml(extractArtifactRels(...))` 在 5 个渲染点都跑过 — `renderMessages` 里 tool 结果卡 / assistant 正文 / assistant tool_calls args 各一处,`handleSseEvent` 里 tool_call / tool_result 各一处。同一 rel 在 tool 结果与紧随 assistant 正文里同时出现(模型 echo 路径)→ 历史回放渲两次。修法:`renderMessages` 顶部建 `const seenRels = new Set()` + `pickFresh(rels)` 闭包,3 个调用点(tool 结果 / assistant 正文 / tool_calls args)全部包一层 — chronological 顺序,首次出现保留(tool 结果常在前),后续重复丢;SSE `ctx` 加 `seenRels: new Set()`,tool_call / tool_result 两 handler 共享去重。**对比 querySelector 版**:DOM 查询版 O(n²)(每条 card 渲染时扫 wrap 已有 `[data-rel]`),Set 版 O(n) 无查询,代码量相同还把"什么是 source of truth"明确(不依赖 DOM 已挂 chip 这个隐式状态)。**CLAUDE.md 增段**:开发期需求漂移快,非平凡改动(改 >1 文件 / 行为变化 / 多候选取舍)动手前先用自然语言把方案讲给用户确认,认可后再写代码;一次性 bug 修 / 字面量 / 样式微调可直接动手。方案描述要包含问题定位(文件 / 行号)+ 至少 1 个替代方案 + 涉及性能 / 兼容 / 数据迁移时主动说。**没动**:`extractArtifactRels` / `renderArtifactBarHtml` 实现(它们内部本身已 Set 去重单次调用内重复)、`_workingDirName` / chip 点击委托 / 媒体 blob 缓存、后端、DESIGN(纯前端 UX 修复)、RUN(无对外行为变化)。
|
||||
|
||||
- **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 懒加载。
|
||||
|
|
|
|||
3
RUN.md
3
RUN.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
|
||||
|
||||
最后更新:2026-05-20(加 `GET /v1/image_models` 列图像 variant + `POST .../messages` body 新增 `image_model` 可选字段)
|
||||
最后更新:2026-05-20(加 `POST /v1/tasks/{id}/optimize_prompt` 辅助 LLM 草稿润色;`usage_events.kind` 新增 `prompt_optimize`)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -129,6 +129,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
|
|||
| `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 文件保留 | 必填 |
|
||||
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
|
||||
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
|
||||
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
|
||||
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
|
||||
|
|
|
|||
140
web/app.py
140
web/app.py
|
|
@ -384,6 +384,13 @@ class MessageRequest(BaseModel):
|
|||
image_model: str = ""
|
||||
|
||||
|
||||
class OptimizePromptRequest(BaseModel):
|
||||
text: str
|
||||
# 选择性传当前 UI 选中的生图 variant key,润色 meta-prompt 会把对应模型特性塞进去
|
||||
# (让 LLM 知道下游 tool 偏好,润色出更贴合 seedream/seedance 等的 prompt)。
|
||||
image_model: str = ""
|
||||
|
||||
|
||||
class FileDeleteRequest(BaseModel):
|
||||
path: str
|
||||
recursive: bool = False
|
||||
|
|
@ -1041,6 +1048,139 @@ def create_app() -> FastAPI:
|
|||
d = _task_dict(task_row, n_messages=0)
|
||||
return d
|
||||
|
||||
# ───────────── Prompt optimize(辅助 LLM 调用,不入 messages)─────────────
|
||||
|
||||
@app.post("/v1/tasks/{task_id}/optimize_prompt", tags=["messages"])
|
||||
def optimize_prompt(
|
||||
task_id: str,
|
||||
body: OptimizePromptRequest,
|
||||
user_id: UUID = Depends(require_user),
|
||||
):
|
||||
"""用 task 当前 model 润色用户草稿 prompt;返回优化后的文本。
|
||||
|
||||
- 同步调用(短文本,3-5s),非 stream
|
||||
- 不入 messages 表;**不**累计到 tasks.tokens_prompt/completion(顶栏数字保持
|
||||
只反映主对话)。usage_events 单独写一行 kind="prompt_optimize",方便对账
|
||||
+ 按 kind GROUP BY 评估"这个按钮值不值"
|
||||
- 不与主对话 run 互斥(它不写 messages,无 idx 竞争)— 用户在 LLM 流式
|
||||
回复期间也可润色下一条草稿
|
||||
- image_model 影响 meta-prompt 里给 LLM 的下游 tool 提示;不动 DB
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from core.agent_builder import load_config
|
||||
from core.capabilities import ModelCapabilities
|
||||
from core.llm import LLM
|
||||
from core.paths import ROOT
|
||||
from core.storage.models import UsageEvent
|
||||
from core.storage.usage import USD_TO_CNY
|
||||
|
||||
try:
|
||||
tid = UUID(task_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, f"invalid task id: {task_id!r}")
|
||||
text = (body.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "empty text")
|
||||
if len(text) > 4000:
|
||||
raise HTTPException(400, "text too long (>4000 chars)")
|
||||
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
select(Task.model_profile)
|
||||
.where(Task.task_id == tid, Task.user_id == user_id)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(404, f"task not found: {tid}")
|
||||
task_model_profile = row.model_profile or ""
|
||||
|
||||
cfg = load_config()
|
||||
chosen_profile = task_model_profile or cfg["default_model"]
|
||||
try:
|
||||
caps = ModelCapabilities.load(chosen_profile, ROOT / cfg["models_dir"])
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(500, f"invalid task model_profile {chosen_profile!r}: {e}")
|
||||
|
||||
# 收集下游 tool 上下文:对话模型 display_name + 当前选中生图 variant 元数据
|
||||
chat_model_display = caps.display_name or chosen_profile
|
||||
image_variant_hint = ""
|
||||
img_variant = (body.image_model or "").strip()
|
||||
if img_variant:
|
||||
for k, v in _list_image_variants():
|
||||
if k == img_variant:
|
||||
name = v.get("display_name") or k
|
||||
sz = v.get("default_size") or "2048x2048"
|
||||
image_variant_hint = (
|
||||
f"\n下游生图工具:{name}(默认尺寸 {sz},支持中英文 prompt,"
|
||||
f"擅长写实/插画/构图描述)。若用户意图涉及画面/封面/插图,"
|
||||
f"润色后的文本要给出适合该模型的画面细节(主体/风格/光线/构图)。"
|
||||
)
|
||||
|
||||
meta_prompt = (
|
||||
f"你的任务是润色用户输入的草稿,使之成为一个清晰、完整、可执行的 prompt。\n"
|
||||
f"当前对话模型:{chat_model_display}。{image_variant_hint}\n\n"
|
||||
f"规则:\n"
|
||||
f"1. 只输出润色后的文本本身,不要任何解释、前后缀、引号、markdown 代码块包裹\n"
|
||||
f"2. 保留用户原始语言(中文/英文)\n"
|
||||
f"3. 补全模糊点(主体、目标、风格、约束),但不要无中生有改变用户意图\n"
|
||||
f"4. 长度合理 — 简短诉求润色后也应当简洁,不要堆砌\n\n"
|
||||
f"用户草稿:\n{text}"
|
||||
)
|
||||
|
||||
llm = LLM(caps)
|
||||
try:
|
||||
response = llm.chat(
|
||||
messages=[{"role": "user", "content": meta_prompt}],
|
||||
tools=None,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"llm call failed: {type(e).__name__}: {e}")
|
||||
|
||||
try:
|
||||
optimized = (response.choices[0].message.content or "").strip()
|
||||
except Exception:
|
||||
raise HTTPException(502, "llm response missing content")
|
||||
if not optimized:
|
||||
raise HTTPException(502, "llm returned empty optimization")
|
||||
|
||||
usage = getattr(response, "usage", None)
|
||||
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
||||
completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
||||
try:
|
||||
from litellm import completion_cost
|
||||
cost_usd_raw = completion_cost(completion_response=response)
|
||||
cost_usd = Decimal(str(cost_usd_raw)) if cost_usd_raw else Decimal("0")
|
||||
except Exception:
|
||||
cost_usd = Decimal("0")
|
||||
cost_cny = (cost_usd * USD_TO_CNY).quantize(Decimal("0.000001"))
|
||||
|
||||
try:
|
||||
with session_scope() as s:
|
||||
s.add(UsageEvent(
|
||||
user_id=user_id,
|
||||
task_id=tid,
|
||||
message_id=None,
|
||||
kind="prompt_optimize",
|
||||
model_profile=chosen_profile,
|
||||
units={
|
||||
"tokens_in": prompt_tokens,
|
||||
"tokens_out": completion_tokens,
|
||||
"usd_to_cny": float(USD_TO_CNY),
|
||||
"image_model_hint": img_variant or "",
|
||||
},
|
||||
cost_cny=cost_cny,
|
||||
))
|
||||
except Exception as e:
|
||||
# 记账失败不阻塞返结果 — 用户拿到润色文本要紧,事后人工补
|
||||
print(f"[optimize_prompt] usage record failed: {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
return {
|
||||
"optimized": optimized,
|
||||
"model_profile": chosen_profile,
|
||||
"tokens_in": prompt_tokens,
|
||||
"tokens_out": completion_tokens,
|
||||
"cost_cny": float(cost_cny),
|
||||
}
|
||||
|
||||
# ───────────── SSE events ─────────────
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/events", tags=["tasks"])
|
||||
|
|
|
|||
|
|
@ -668,7 +668,7 @@
|
|||
<div class="row">
|
||||
<span class="hint" id="chat-hint">就绪</span>
|
||||
<span style="flex:1;"></span>
|
||||
<button type="button" class="small" id="chat-upload" title="上传文件到右侧当前文件目录">⬆ 上传</button>
|
||||
<button type="button" class="small" id="chat-optimize" disabled title="用当前对话模型润色草稿(参考已选生图模型偏好)— 替换为更清晰可执行的 prompt,Ctrl+Z 可撤销">✨ 润色</button>
|
||||
<button type="submit" class="primary" id="chat-action">发送</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -785,6 +785,9 @@ const state = {
|
|||
// 当前选中的图像生成 variant key(per-session,不入 DB);默认 = imageModels[0].variant
|
||||
// (=yaml 第一个 = agent_builder fallback)。下次 send 消息时随 POST body 带给 backend。
|
||||
imageModel: "",
|
||||
// 润色按钮进行中标记:防止双击,同时让 syncOptimizeBtn 在 in-flight 期间不覆盖
|
||||
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
|
||||
optimizing: false,
|
||||
};
|
||||
|
||||
// ───── helpers ─────
|
||||
|
|
@ -1287,6 +1290,7 @@ function renderChatMeta() {
|
|||
if (imgSel) imgSel.onchange = onChangeImageModel;
|
||||
const active = t.status === "active";
|
||||
$("chat-form").style.display = active ? "flex" : "none";
|
||||
syncOptimizeBtn();
|
||||
$("btn-done").disabled = !active;
|
||||
$("btn-abandon").disabled = !active;
|
||||
$("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm)
|
||||
|
|
@ -1468,6 +1472,63 @@ $("chat-input").addEventListener("keydown", (e) => {
|
|||
if (!state.streaming) sendMessage();
|
||||
}
|
||||
});
|
||||
$("chat-input").addEventListener("input", syncOptimizeBtn);
|
||||
|
||||
// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
|
||||
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
|
||||
// 不与主对话 run 互斥,各跑各的 LLM)。
|
||||
function syncOptimizeBtn() {
|
||||
const btn = $("chat-optimize");
|
||||
if (!btn) return;
|
||||
if (state.optimizing) return; // 进行中不在这条路径切
|
||||
const has = ($("chat-input").value || "").trim().length > 0;
|
||||
btn.disabled = !has || !state.taskId;
|
||||
}
|
||||
|
||||
async function optimizePrompt() {
|
||||
if (state.optimizing) return;
|
||||
if (!state.taskId) return;
|
||||
const ta = $("chat-input");
|
||||
const original = (ta.value || "").trim();
|
||||
if (!original) return;
|
||||
const btn = $("chat-optimize");
|
||||
state.optimizing = true;
|
||||
btn.disabled = true;
|
||||
const oldLabel = btn.textContent;
|
||||
btn.textContent = "润色中…";
|
||||
const oldHint = $("chat-hint").textContent;
|
||||
$("chat-hint").textContent = "润色中…";
|
||||
try {
|
||||
const r = await api("POST", `/v1/tasks/${state.taskId}/optimize_prompt`, {
|
||||
text: ta.value, // 不 trim — 后端再 strip;保留尾部 newline 让用户感受不变
|
||||
image_model: state.imageModel || "",
|
||||
});
|
||||
const optimized = (r.optimized || "").trim();
|
||||
if (!optimized) throw new Error("空结果");
|
||||
// execCommand('insertText') 把"全选 + 替换"作为一个 undo 单元接入 textarea 原生栈
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand("insertText", false, optimized);
|
||||
if (!ok) {
|
||||
// execCommand 在某些环境(contentEditable=false 旧 Firefox)失败 — 兜底直接赋值
|
||||
// 这种情况下 Ctrl+Z 失效,但功能不阻塞;贴提示让用户知道
|
||||
ta.value = optimized;
|
||||
$("chat-hint").textContent = "已润色(本浏览器不支持撤销,需自行保留草稿)";
|
||||
} else {
|
||||
const cost = typeof r.cost_cny === "number" ? r.cost_cny.toFixed(4) : "?";
|
||||
$("chat-hint").textContent = `已润色 · ${r.tokens_in || 0}+${r.tokens_out || 0} tok · ¥${cost} · Ctrl+Z 撤销`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
$("chat-hint").textContent = `润色失败:${e.message}`;
|
||||
} finally {
|
||||
state.optimizing = false;
|
||||
btn.textContent = oldLabel;
|
||||
syncOptimizeBtn();
|
||||
}
|
||||
}
|
||||
|
||||
$("chat-optimize").onclick = optimizePrompt;
|
||||
|
||||
// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
|
||||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
||||
|
|
@ -1513,6 +1574,7 @@ async function sendMessage() {
|
|||
image_model: state.imageModel || "",
|
||||
});
|
||||
$("chat-input").value = "";
|
||||
syncOptimizeBtn();
|
||||
state.streaming = true;
|
||||
setActionMode("streaming");
|
||||
streamSse(r.events_url, asstCard);
|
||||
|
|
@ -1761,7 +1823,6 @@ function exportTask(tid) {
|
|||
// ───── files(user-rooted,不绑 task) ─────
|
||||
$("btn-refresh-files").onclick = () => loadFiles();
|
||||
$("btn-upload").onclick = () => $("upload-input").click();
|
||||
$("chat-upload").onclick = () => $("upload-input").click();
|
||||
$("upload-input").addEventListener("change", uploadSelected);
|
||||
|
||||
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
|
||||
|
|
|
|||
Loading…
Reference in New Issue