Compare commits

...

18 Commits

Author SHA1 Message Date
肖应宇 1d031f4577 fix: 调整dropdown,tag的间距 2026-04-17 17:02:23 +08:00
肖应宇 eed425e965 feat(frontend): 增强建议快捷skill工具多层提示交互并更新计划状态 2026-04-17 15:17:53 +08:00
肖应宇 3d5a6a54ca docs(gsd): update post-milestone next-step guidance 2026-04-17 14:20:18 +08:00
肖应宇 92a0be5274 feat(phase-07): finalize mention prompt behavior and docs 2026-04-17 14:13:54 +08:00
肖应宇 ebb9ca7140 docs(gsd): archive v1.0 milestone documentation 2026-04-17 14:13:48 +08:00
肖应宇 d7d9da67f6 docs(phase-07): add/update validation strategy 2026-04-17 13:56:08 +08:00
肖应宇 33705637ea fix(artifacts): 修复文件名差异导致 Artifact not found 2026-04-17 13:50:44 +08:00
肖应宇 c667faad65 docs(phase-07): add/update security threat verification 2026-04-17 13:44:57 +08:00
肖应宇 dc534e993e test(07): complete UAT - 3 passed, 0 issues 2026-04-17 13:43:29 +08:00
肖应宇 fdff86e5b7 docs(07): add verified gap-closure plan 2026-04-17 11:30:58 +08:00
肖应宇 f96bbafa32 docs(07): add root causes from diagnosis 2026-04-17 11:30:18 +08:00
肖应宇 67e036dc23 test(07): complete UAT - 0 passed, 3 issues 2026-04-17 11:29:02 +08:00
肖应宇 27414fc4e1 feat(phase-07): compose attachment/skill priority hints on submit 2026-04-17 11:00:12 +08:00
肖应宇 326c780ab7 docs(state): mark phase 7 planned 2026-04-17 10:49:39 +08:00
肖应宇 94094f7563 docs(07): add plan and validation strategy 2026-04-17 10:49:33 +08:00
肖应宇 80cfd8b899 docs(07): capture phase context 2026-04-17 10:43:52 +08:00
肖应宇 830c8abcf1 feat(ZoomSelector): 使用Slider组件控制字体 2026-04-17 10:33:05 +08:00
肖应宇 b88fa12214 feat(skillSelect): 将localstorage换成sessionStorage,且不从sessionstorage自动恢复显示skill 2026-04-17 09:38:58 +08:00
45 changed files with 2120 additions and 255 deletions

View File

@ -1,5 +1,27 @@
# Milestones # Milestones
## v1.0 v1.0 (Shipped: 2026-04-17)
**Phases completed:** 8 phases, 13 plans, 14 tasks
**Key accomplishments:**
- 交付了可复现冲突证据链、文件级风险清单与 Titan 重叠决策矩阵,形成“旧视觉+新逻辑”执行输入。
- 线程路由从 isnew 参数切换为路由单路径语义,并将 skills bootstrap 合同统一到 content_ids。
- 完成 03-UAT 的关键 gap 收敛lint 阻塞清零welcome-and-routing 从 4 失败收敛到 0 失败。
- 基于 originui 合并基线完成 Phase 3 执行记录,并输出可审计的视觉与回归验证结果。
- 完成 Phase 4 首轮执行iframe 通信与导出链路加入前端容错,目标 lint/E2E 验证通过。
- Phase 5 执行完成:目标 E2E 套件达到“0 失败、可解释 skip”并形成提交卫生分组建议。
- 完成引用提交契约与软失败链路,确保 uploads + references 统一进 `additional_kwargs.files`
- 完成输入框 `@` 引用交互闭环候选展示、过滤、选择、chip 渲染、删除、键盘操作与上限控制。
- 补齐 Phase 6 的验证与提交卫生材料,并记录了可复现的 E2E 环境阻塞证据。
- 输入框 `@` 引用链路已收口:候选贴边定位、内嵌引用预览与 6 个上限、artifact 引用可转为上下文可消费的 uploads 契约。
- Phase 06 最后一个 gap-closure 计划已收口:输入框引用合同重新对齐 requirement=10DF-INPUT-008/009 都已变成可重复运行的稳定回归。
- Phase 06 的执行文档已闭环,提交顺序与验证证据可直接供后续 verify-work 与审阅使用。
- Phase 06 已完成 `@` 文件引用能力artifacts + uploads及提交契约收敛并具备可审计验证材料。
---
## v1.0 milestone (Shipped: 2026-04-15) ## v1.0 milestone (Shipped: 2026-04-15)
**Phases completed:** 6 phases, 10 plans, 14 tasks **Phases completed:** 6 phases, 10 plans, 14 tasks

View File

@ -67,5 +67,17 @@ Plans:
- [x] 06-04-ARCHIVED.md — 修订归档:原 gap-closure 计划与锁定决策 D-08上限 10冲突保留追踪但不再执行 - [x] 06-04-ARCHIVED.md — 修订归档:原 gap-closure 计划与锁定决策 D-08上限 10冲突保留追踪但不再执行
- [ ] 06-05-PLAN.md — 关闭 verification 缺口:恢复 10 个上限/类型去歧义,并稳定 DF-INPUT-008/009 回归 - [ ] 06-05-PLAN.md — 关闭 verification 缺口:恢复 10 个上限/类型去歧义,并稳定 DF-INPUT-008/009 回归
### Phase 7: 发送时拼接附件与Skill优先提示词并在消息区过滤
**Goal:** 发送消息时拼接附件/Skill优先提示词同时消息区仅展示用户原文。
**Requirements**: P7-01, P7-02, P7-03, P7-04
**Depends on:** Phase 6
**Plans:** 2/2 plans complete
Plans:
- [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
- [x] 07-02-PLAN.md — gap closure修复 ContextMenu 自动引用、提示前缀唯一化、Skill 使用 id 拼接
--- ---
*Next command:* `/gsd-verify-work` *Milestone status:* `in_progress`
*Next command:* `/gsd-execute-phase 6`

View File

@ -2,13 +2,14 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: Executing Phase 06 status: v1.0 milestone in progress
last_updated: "2026-04-16T06:58:00Z" last_updated: "2026-04-17T06:09:01.300Z"
last_activity: 2026-04-17
progress: progress:
total_phases: 6 total_phases: 8
completed_phases: 6 completed_phases: 7
total_plans: 11 total_plans: 13
completed_plans: 13 completed_plans: 16
percent: 100 percent: 100
--- ---
@ -19,13 +20,13 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-07) See: .planning/PROJECT.md (updated 2026-04-07)
**Core value:** Keep the frontend visually familiar while preserving and hardening new-system behavior end to end. **Core value:** Keep the frontend visually familiar while preserving and hardening new-system behavior end to end.
**Current focus:** Phase 06 — 06 **Current focus:** Milestone v1.0 in progress
## Workflow State ## Workflow State
- Current workflow: execute-phase completed (phase 06) - Current workflow: milestone execution (v1.0)
- Next workflow: verify-work - Next workflow: execute-phase
- Next command: /gsd-verify-work - Next command: /gsd-execute-phase 6
## Artifacts ## Artifacts
@ -44,7 +45,7 @@ See: .planning/PROJECT.md (updated 2026-04-07)
### Roadmap Evolution ### Roadmap Evolution
- Phase 6 added: 在输入框输入@时,可引用已生成文件和已上传附件 - Phase 6 added: 在输入框输入@时,可引用已生成文件和已上传附件
- Phase 7 added: Phase 06 验收后补丁归档mention/upload语义与附件预览复用 - Phase 7 added: 发送时拼接附件与Skill优先提示词并在消息区过滤
### Quick Tasks Completed ### Quick Tasks Completed
@ -52,5 +53,6 @@ See: .planning/PROJECT.md (updated 2026-04-07)
|---|-------------|------|--------|-----------| |---|-------------|------|--------|-----------|
| 260415-owq | 归档当前git diff为Phase 06验收后补丁检查改动、更新06-UAT/06-VERIFICATION/06-SUMMARY(必要时)与STATE再做原子提交 | 2026-04-15 | atomic | [260415-owq-git-diff-phase-06-06-uat-06-verification](./quick/260415-owq-git-diff-phase-06-06-uat-06-verification/) | | 260415-owq | 归档当前git diff为Phase 06验收后补丁检查改动、更新06-UAT/06-VERIFICATION/06-SUMMARY(必要时)与STATE再做原子提交 | 2026-04-15 | atomic | [260415-owq-git-diff-phase-06-06-uat-06-verification](./quick/260415-owq-git-diff-phase-06-06-uat-06-verification/) |
| 260416-koe | 归档 Phase 06 明确指代(“这张图”)语义修复到 GSD 流程(已验收,通过人工确认,免验证) | 2026-04-16 | pending | [260416-koe-phase-06](./quick/260416-koe-phase-06/) | | 260416-koe | 归档 Phase 06 明确指代(“这张图”)语义修复到 GSD 流程(已验收,通过人工确认,免验证) | 2026-04-16 | pending | [260416-koe-phase-06](./quick/260416-koe-phase-06/) |
| 260417-kcb | suggestion hover dropdown + child tooltip + bootstrap ids/sessionStorage | 2026-04-17 | pending | [260417-kcb-suggestion-hover-dropdown-child-tooltip-](./quick/260417-kcb-suggestion-hover-dropdown-child-tooltip-/) |
Last activity: 2026-04-16 - Completed quick task 260416-koe: 归档 Phase 06 明确指代(“这张图”)语义修复到 GSD 流程(已验收,通过人工确认,免验证) Last activity: 2026-04-17 - Completed quick task 260417-kcb: suggestion hover dropdown + child tooltip + bootstrap ids/sessionStorage

View File

@ -0,0 +1,200 @@
---
milestone: v1.0
audited: 2026-04-17T06:05:06Z
status: gaps_found
scores:
requirements: 6/17
phases: 2/7
integration: 1/1
flows: 0/2
gaps:
requirements:
- id: "MERGE-02"
status: "orphaned"
phase: "Phase 1"
claimed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-PLAN.md"]
completed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-SUMMARY.md"]
verification_status: "orphaned"
evidence: "Listed in SUMMARY frontmatter, but absent from all phase VERIFICATION.md files (only 01 and 06 verification files exist)."
- id: "LOGIC-03"
status: "orphaned"
phase: "Phase 2"
claimed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-PLAN.md"]
completed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-SUMMARY.md"]
verification_status: "orphaned"
evidence: "Traceability marks complete, but no phase VERIFICATION coverage; integration audit also flags xclaw_used compatibility gap."
- id: "LOGIC-04"
status: "orphaned"
phase: "Phase 2"
claimed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-PLAN.md"]
completed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-SUMMARY.md"]
verification_status: "orphaned"
evidence: "Claimed in SUMMARY, absent from all VERIFICATION.md; integration audit flags legacy content_id adapter risk."
- id: "UI-01"
status: "orphaned"
phase: "Phase 3"
claimed_by_plans: [".planning/phases/03-legacy-visual-alignment-pass/03-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Not listed in requirements-completed frontmatter and no phase VERIFICATION.md exists for Phase 3."
- id: "UI-02"
status: "orphaned"
phase: "Phase 3"
claimed_by_plans: [".planning/phases/03-legacy-visual-alignment-pass/03-PLAN.md", ".planning/phases/03-legacy-visual-alignment-pass/03-02-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Mentioned as targeted in summaries but not in requirements-completed frontmatter and no VERIFICATION.md exists."
- id: "UI-03"
status: "orphaned"
phase: "Phase 3"
claimed_by_plans: [".planning/phases/03-legacy-visual-alignment-pass/03-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "No requirements-completed frontmatter evidence and no phase VERIFICATION.md exists."
- id: "LOGIC-01"
status: "orphaned"
phase: "Phase 4"
claimed_by_plans: [".planning/phases/04-iframe-markdown-new-system-stabilization/04-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Only targeted in summary body; no requirements-completed frontmatter and no phase VERIFICATION.md exists."
- id: "LOGIC-02"
status: "orphaned"
phase: "Phase 4"
claimed_by_plans: [".planning/phases/04-iframe-markdown-new-system-stabilization/04-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Only targeted in summary body; no requirements-completed frontmatter and no phase VERIFICATION.md exists."
- id: "TEST-01"
status: "orphaned"
phase: "Phase 5"
claimed_by_plans: [".planning/phases/05-test-hardening-and-commit-hygiene/05-PLAN.md", ".planning/phases/03-legacy-visual-alignment-pass/03-02-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Targeted in summary text but not requirements-completed frontmatter and no phase VERIFICATION.md exists."
- id: "TEST-02"
status: "orphaned"
phase: "Phase 5"
claimed_by_plans: [".planning/phases/05-test-hardening-and-commit-hygiene/05-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "No phase VERIFICATION.md exists for Phase 5; traceability still pending."
- id: "TEST-03"
status: "orphaned"
phase: "Phase 5"
claimed_by_plans: [".planning/phases/05-test-hardening-and-commit-hygiene/05-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "No phase VERIFICATION.md exists for Phase 5; integration audit additionally flags missing 07-VERIFICATION as auditability gap."
integration:
- from: "Phase 2"
to: "Phase 2/7 runtime"
issue: "LOGIC-03 requires xclaw_used handling, but runtime consumer is not present in code path."
- from: "Phase 2"
to: "Phase 4/7 runtime"
issue: "Legacy content_id adapter evidence is incomplete; content_ids-only flow may not satisfy LOGIC-04 compatibility claim."
flows:
- name: "Legacy compatibility flow (thread_id/isnew/xclaw_used)"
break_at: "xclaw_used ingestion/propagation"
evidence: "No code-path consumer found; flagged by integration checker."
- name: "Verification evidence flow"
break_at: "Phase verification artifact generation"
evidence: "Phases 02/03/04/05/07 are missing *-VERIFICATION.md."
tech_debt:
- phase: "02-thread-and-skills-logic-reconciliation"
items:
- "E2E was environment-blocked during summary run (ERR_CONNECTION_REFUSED at 127.0.0.1:2026)."
- "Summary/code drift noted for referenced files in integration audit."
- phase: "03-legacy-visual-alignment-pass"
items:
- "Execution relied on merged dirty baseline with blockers deferred across phases."
- phase: "04-iframe-markdown-new-system-stabilization"
items:
- "5 E2E skips recorded for fixture/history-dependent paths."
- phase: "05-test-hardening-and-commit-hygiene"
items:
- "10 E2E skips remain, explained but still deferred reliability debt."
- phase: "06-"
items:
- "06-VALIDATION.md status is draft despite nyquist_compliant true."
- phase: "07-phase-06-mention-upload"
items:
- "07-VALIDATION exists without 07-VERIFICATION artifact."
nyquist:
compliant_phases: ["06", "07"]
partial_phases: []
missing_phases: ["01", "02", "03", "04", "05"]
overall: "partial"
---
# Milestone v1.0 Audit
## Scope
- Milestone: `v1.0`
- In-scope phase directories:
- `.planning/phases/01-conflict-inventory-and-decision-matrix`
- `.planning/phases/02-thread-and-skills-logic-reconciliation`
- `.planning/phases/03-legacy-visual-alignment-pass`
- `.planning/phases/04-iframe-markdown-new-system-stabilization`
- `.planning/phases/05-test-hardening-and-commit-hygiene`
- `.planning/phases/06-`
- `.planning/phases/07-phase-06-mention-upload`
## Phase Verification Coverage
| Phase | VERIFICATION.md | Status |
|---|---|---|
| 01 | present | passed |
| 02 | missing | unverified (blocker) |
| 03 | missing | unverified (blocker) |
| 04 | missing | unverified (blocker) |
| 05 | missing | unverified (blocker) |
| 06 | present | passed |
| 07 | missing | unverified (blocker) |
## Requirements 3-Source Cross-Reference
| REQ-ID | Traceability | VERIFICATION Source | SUMMARY `requirements-completed` | Final |
|---|---|---|---|---|
| MERGE-01 | Complete | passed (01) | listed | satisfied |
| MERGE-02 | Complete | missing/orphaned | listed | unsatisfied (orphaned) |
| MERGE-03 | Complete | passed (01) | listed | satisfied |
| LOGIC-03 | Complete | missing/orphaned | listed | unsatisfied (orphaned) |
| LOGIC-04 | Complete | missing/orphaned | listed | unsatisfied (orphaned) |
| UI-01 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| UI-02 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| UI-03 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| LOGIC-01 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| LOGIC-02 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| TEST-01 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| TEST-02 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| TEST-03 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| ATREF-01 | Pending | passed (06) | listed | satisfied (checkbox stale) |
| ATREF-02 | Pending | passed (06) | listed | satisfied (checkbox stale) |
| ATREF-03 | Pending | passed (06) | listed | satisfied (checkbox stale) |
| ATREF-04 | Pending | passed (06) | listed | satisfied (checkbox stale) |
### FAIL Gate
`gaps_found` is enforced because unsatisfied requirements exist (11), including orphaned requirements assigned in traceability but absent from all phase VERIFICATION files.
## Integration Checker Results
### Critical
- No critical integration break found across phases 2 to 7.
### Non-Critical
- LOGIC-03 compatibility gap (`xclaw_used` path not evidenced in runtime).
- LOGIC-04 compatibility risk (legacy adapter evidence incomplete).
- Phase 2 summary/code artifact drift.
- Phase 7 has validation but no verification artifact.
## Broken Flows
- Legacy compatibility flow (`thread_id/isnew/xclaw_used`) breaks at xclaw_used ingestion/propagation.
- Verification evidence flow breaks at missing phase-level VERIFICATION artifacts.
## Overall Conclusion
Milestone `v1.0` is **not ready to complete** under current audit gates. Requirements and integration implementation are substantial, but verification artifacts are incomplete for multiple phases, causing orphaned requirements and mandatory `gaps_found` status.

View File

@ -1,6 +1,6 @@
# Requirements Archive: v1.0 milestone # Requirements Archive: v1.0 v1.0
**Archived:** 2026-04-15 **Archived:** 2026-04-17
**Status:** SHIPPED **Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`. For current requirements, see `.planning/REQUIREMENTS.md`.

View File

@ -67,5 +67,17 @@ Plans:
- [x] 06-04-ARCHIVED.md — 修订归档:原 gap-closure 计划与锁定决策 D-08上限 10冲突保留追踪但不再执行 - [x] 06-04-ARCHIVED.md — 修订归档:原 gap-closure 计划与锁定决策 D-08上限 10冲突保留追踪但不再执行
- [ ] 06-05-PLAN.md — 关闭 verification 缺口:恢复 10 个上限/类型去歧义,并稳定 DF-INPUT-008/009 回归 - [ ] 06-05-PLAN.md — 关闭 verification 缺口:恢复 10 个上限/类型去歧义,并稳定 DF-INPUT-008/009 回归
### Phase 7: 发送时拼接附件与Skill优先提示词并在消息区过滤
**Goal:** 发送消息时拼接附件/Skill优先提示词同时消息区仅展示用户原文。
**Requirements**: P7-01, P7-02, P7-03, P7-04
**Depends on:** Phase 6
**Plans:** 2/2 plans complete
Plans:
- [x] 07-01-PLAN.md — 提交态增强文本组装 + 三入口统一透传 + 显示态/提交态分离回归
- [x] 07-02-PLAN.md — gap closure修复 ContextMenu 自动引用、提示前缀唯一化、Skill 使用 id 拼接
--- ---
*Next command:* `/gsd-verify-work` *Milestone status:* `in_progress`
*Next command:* `/gsd-execute-phase 6`

View File

@ -0,0 +1,211 @@
---
phase: 07-phase-06-mention-upload
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- frontend/src/components/workspace/input-box.tsx
- frontend/src/core/threads/hooks.ts
- frontend/src/components/ai-elements/prompt-input.tsx
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/src/core/i18n/locales/zh-CN.ts
- frontend/src/core/i18n/locales/en-US.ts
- frontend/src/core/i18n/locales/types.ts
- frontend/src/core/threads/hooks.test.ts
- frontend/tests/e2e/input-and-compose.spec.ts
autonomous: true
requirements:
- P7-01
- P7-02
- P7-03
- P7-04
must_haves:
truths:
- "发送到后端的文本会拼接优先使用…附件和…Skill但消息区仅展示用户原文。"
- "拼接规则固定附件在前、Skill在后单类单出大小写不敏感去重。"
- "按钮发送、回车发送、建议词自动发送三条入口行为一致。"
artifacts:
- path: "frontend/src/core/threads/hooks.ts"
provides: "提交态增强文本与展示态原文分离"
contains: "payload text composition"
- path: "frontend/src/components/workspace/input-box.tsx"
provides: "references + selectedSkills 元数据传递"
contains: "handleSubmit"
- path: "frontend/src/components/workspace/messages/message-list-item.tsx"
provides: "人类消息渲染仍以原文为准"
contains: "contentToDisplay"
key_links:
- from: "frontend/src/components/workspace/input-box.tsx"
to: "frontend/src/core/threads/hooks.ts"
via: "PromptInputMessage 扩展字段"
pattern: "selectedSkills/references -> payload composition"
- from: "frontend/src/core/threads/hooks.ts"
to: "frontend/src/components/workspace/messages/message-list-item.tsx"
via: "optimistic content + persisted display consistency"
pattern: "original text only"
---
<objective>
实现 Phase 7 决策:发送时将附件与 Skill 提示文案拼接进提交给后端的提示词,但消息区不展示拼接内容。
Purpose: 在不破坏既有 `additional_kwargs.files` 语义和输入体验的前提下,增强模型侧提示优先级。
Output: 形成稳定的“提交态增强文本/展示态原文”链路,并由单测 + E2E 回归覆盖。
</objective>
<execution_context>
@/home/mt/.codex/get-shit-done/workflows/execute-plan.md
@/home/mt/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/STATE.md
@.planning/phases/07-phase-06-mention-upload/07-CONTEXT.md
@.planning/phases/07-phase-06-mention-upload/07-RESEARCH.md
@.planning/phases/07-phase-06-mention-upload/07-VALIDATION.md
@frontend/src/components/workspace/input-box.tsx
@frontend/src/core/threads/hooks.ts
@frontend/src/components/ai-elements/prompt-input.tsx
@frontend/src/components/workspace/messages/message-list-item.tsx
@frontend/tests/e2e/input-and-compose.spec.ts
<interfaces>
From frontend/src/components/ai-elements/prompt-input.tsx:
```typescript
export type PromptInputMessage = {
text: string;
files: FileUIPart[];
references?: PromptInputReference[];
};
```
From frontend/src/core/threads/hooks.ts:
```typescript
const sendMessage = async (threadId: string | undefined, message: PromptInputMessage) => {
const text = message.text.trim();
// optimistic human message + submit payload
};
```
From frontend/src/components/workspace/input-box.tsx:
```typescript
onSubmit?.({ ...message, references });
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: 设计并接入“提交态增强文本”组装器</name>
<files>frontend/src/core/threads/hooks.ts, frontend/src/components/ai-elements/prompt-input.tsx</files>
<read_first>
- .planning/phases/07-phase-06-mention-upload/07-CONTEXT.md
- frontend/src/core/threads/hooks.ts
- frontend/src/components/ai-elements/prompt-input.tsx
- frontend/src/core/threads/submit-files.ts
</read_first>
<action>
扩展 `PromptInputMessage` 以承载发送时需要的 Skill 名列表(例如 `selectedSkills?: Array<{ title: string }>`),并在 `hooks.ts` 中新增纯函数组装器:输入原文、附件名集合(上传文件名 + references 文件名、Skill 名集合输出“提交态增强文本”。规则必须写死为附件在前、Skill在后、单类单出、大小写不敏感去重、空集合不拼接。拼接模板使用 `优先使用【...】和【...】`。保持 `additional_kwargs.files` 现有逻辑不变,不新建并行 envelope。
</action>
<acceptance_criteria>
- `PromptInputMessage` 新增可选 Skill 元数据字段,类型定义与调用点一致。
- `hooks.ts` 存在独立组装函数,且可单测验证 4 条决策规则(顺序、单类单出、去重、空值)。
- 原 `buildFilesForSubmit``additional_kwargs.files` 流程未被改写为新结构。
</acceptance_criteria>
<verify>
<automated>cd frontend && rg -n "selectedSkills\?:|build.*Priority|优先使用【" src/components/ai-elements/prompt-input.tsx src/core/threads/hooks.ts</automated>
<automated>cd frontend && pnpm -s test -- --run src/core/threads/hooks.test.ts</automated>
</verify>
<done>提交链路具备可复用的“增强文本组装器”,且不破坏现有文件提交协议。</done>
</task>
<task type="auto">
<name>Task 2: InputBox 透传引用与 Skill 元数据,统一三类发送入口</name>
<files>frontend/src/components/workspace/input-box.tsx, frontend/src/app/workspace/chats/[thread_id]/page.tsx</files>
<read_first>
- .planning/phases/07-phase-06-mention-upload/07-CONTEXT.md
- frontend/src/components/workspace/input-box.tsx
- frontend/src/app/workspace/chats/[thread_id]/page.tsx
- frontend/src/hooks/use-iframe-skill.ts
</read_first>
<action>
`InputBox.handleSubmit` 中把当前 `references` 与已选 `selectedSkills` 一并传给 `onSubmit` 消息对象,确保按钮发送、回车发送、建议词自动发送都经过同一条 `requestSubmit -> handleSubmit` 链路,避免分支漏传。禁止直接修改 textarea 展示文本来承载拼接文案;输入框显示始终保持用户原文。
</action>
<acceptance_criteria>
- `onSubmit` 入参中包含 `references``selectedSkills`,且类型安全。
- `handleFollowupClick/confirmReplaceAndSend/confirmAppendAndSend` 最终提交均走相同 `handleSubmit` 透传逻辑。
- 输入框展示值不被拼接文案污染。
</acceptance_criteria>
<verify>
<automated>cd frontend && rg -n "selectedSkills|onSubmit\?\(\{\.\.\.message" src/components/workspace/input-box.tsx</automated>
<automated>cd frontend && pnpm -s test -- --run src/components/workspace/input-box</automated>
</verify>
<done>所有发送入口都带齐元数据并保持展示态原文。</done>
</task>
<task type="auto">
<name>Task 3: 保证消息区仅展示原文并补齐回归</name>
<files>frontend/src/core/threads/hooks.ts, frontend/src/components/workspace/messages/message-list-item.tsx, frontend/tests/e2e/input-and-compose.spec.ts, frontend/src/core/i18n/locales/zh-CN.ts, frontend/src/core/i18n/locales/en-US.ts, frontend/src/core/i18n/locales/types.ts</files>
<read_first>
- .planning/phases/07-phase-06-mention-upload/07-CONTEXT.md
- frontend/src/core/threads/hooks.ts
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/tests/e2e/input-and-compose.spec.ts
- frontend/src/core/i18n/locales/zh-CN.ts
- frontend/src/core/i18n/locales/en-US.ts
- frontend/src/core/i18n/locales/types.ts
</read_first>
<action>
`sendMessage` 中区分 `displayText`(原文)与 `submitText`(原文+拼接文案optimistic human message 和消息渲染侧使用 `displayText`,提交给 `thread.submit` 使用 `submitText`。若后端回流的人类消息可能带拼接文案,则在渲染层加最小且明确的剥离逻辑(仅剥离本阶段固定模板尾段),但不得依赖宽泛正则误伤用户内容。新增 i18n 文案键用于提示拼接规则相关错误(若需要)。补 E2E断言发送后消息区不出现“优先使用【”片段同时请求提交内容包含拼接片段可通过拦截请求或 mock 验证)。
</action>
<acceptance_criteria>
- 发送请求文本包含拼接文案;消息区可见文本不包含拼接文案。
- 附件/Skill 名拼接顺序与去重规则符合 D-01~D-10。
- 新增回归测试覆盖“显示态与提交态分离”主路径。
</acceptance_criteria>
<verify>
<automated>cd frontend && pnpm -s test -- --run src/core/threads/hooks.test.ts</automated>
<automated>cd frontend && pnpm -s test:e2e --grep "优先使用|input|compose"</automated>
<automated>cd frontend && pnpm -s typecheck</automated>
</verify>
<done>端到端满足“拼接给模型但不展示给用户”的核心目标。</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 输入框展示态 → 提交态 payload | 同一条用户消息在展示与提交存在双态,若处理不当会造成信息泄露或行为不一致。 |
| 前端组装器 → 后端存档消息 | 拼接文案若回流到历史消息,会暴露内部引导提示并污染用户可见记录。 |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-01 | I | `frontend/src/core/threads/hooks.ts` | mitigate | 明确区分 `displayText`/`submitText`,并通过测试验证消息区不回显拼接文本。 |
| T-07-02 | T | `frontend/src/components/workspace/input-box.tsx` | mitigate | 强制三入口走同一提交链路,避免某入口漏传 references/skills 造成规则绕过。 |
| T-07-03 | R | `frontend/tests/e2e/input-and-compose.spec.ts` | mitigate | 增加请求拦截断言,确保“显示态/提交态分离”可审计、可回归。 |
</threat_model>
<verification>
- `cd frontend && pnpm -s lint`
- `cd frontend && pnpm -s typecheck`
- `cd frontend && pnpm -s test -- --run src/core/threads/hooks.test.ts`
- `cd frontend && pnpm -s test:e2e --grep "input|compose|优先使用"`
</verification>
<success_criteria>
- 拼接模板与数据口径完全符合 1A/2A/3A/4A 决策。
- 消息区不展示拼接附加文本,且不影响现有附件/引用渲染。
- 三类发送入口行为一致并被自动化回归覆盖。
</success_criteria>
<output>
After completion, create `.planning/phases/07-phase-06-mention-upload/07-01-SUMMARY.md`
</output>

View File

@ -0,0 +1,60 @@
---
phase: 07-phase-06-mention-upload
plan: 01
subsystem: prompt-submit-and-display-separation
tags: [prompt-compose, references, skills, message-display, e2e]
requires:
- phase: 07-phase-06-mention-upload
provides: 07-01-PLAN.md
provides:
- 提交态拼接“优先使用”提示附件优先Skill次之
- 显示态与提交态分离(消息区不回显拼接提示)
- 规则单测与发送链路 e2e 回归
affects: [frontend-chat-input, thread-submit-payload, message-render]
tech-stack:
added:
- frontend/src/core/threads/priority-hint.ts
patterns:
- compose-before-submit with original-display preservation
- case-insensitive dedupe for attachment/skill labels
key-files:
created:
- .planning/phases/07-phase-06-mention-upload/07-01-SUMMARY.md
- frontend/src/core/threads/priority-hint.ts
modified:
- frontend/src/core/threads/hooks.ts
- frontend/src/core/threads/hooks.test.ts
- frontend/src/components/workspace/input-box.tsx
- frontend/src/components/ai-elements/prompt-input.tsx
- frontend/src/core/messages/utils.ts
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/tests/e2e/input-and-compose.spec.ts
key-decisions:
- "发送 payload 使用 submitText消息显示继续使用用户原文。"
- "拼接模板固定为:优先使用【附件...】和【Skill...】;单类单出;大小写不敏感去重。"
- "在渲染层仅剥离固定后缀,避免拼接文案回显到用户消息区。"
requirements-completed: [P7-01, P7-02, P7-03, P7-04]
duration: 45 min
completed: 2026-04-17
---
# Phase 07 Plan 01 Summary
实现了“提交态增强文本 / 显示态原文”的完整链路:发送时自动拼接附件与 Skill 优先提示,消息区仍只展示用户输入内容。
## Implemented
- 新增 `priority-hint` 纯函数模块,封装 `buildPriorityHintText``composeSubmitText`
- `InputBox` 在提交时统一透传 `references + selectedSkills`,覆盖按钮发送、回车发送、建议词发送路径。
- `useThreadStream``useSubmitThread` 在调用 `thread.submit` 前组装 `submitText`
- `message-list-item` 渲染人类消息时增加固定后缀剥离,避免回显“优先使用【...】”。
## Verification
- `node --test frontend/src/core/threads/hooks.test.ts`7 passed
- `cd frontend && pnpm -s typecheck`passed
- `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A"`passed
## Notes
- 本次新增 e2e 用例验证“请求体包含拼接文案,消息区不显示拼接文案”的核心回归场景。

View File

@ -0,0 +1,109 @@
---
phase: 07-phase-06-mention-upload
plan: 02
type: execute
wave: 1
depends_on:
- 07-01
files_modified:
- frontend/src/components/workspace/artifacts/artifact-file-list.tsx
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/src/core/threads/hooks.ts
- frontend/src/core/threads/priority-hint.ts
- frontend/src/core/messages/utils.ts
- frontend/src/core/threads/hooks.test.ts
- frontend/tests/e2e/input-and-compose.spec.ts
autonomous: true
gap_closure: true
requirements:
- P7-01
- P7-02
- P7-03
- P7-04
must_haves:
truths:
- "右键仅打开 ContextMenu不会在未点击引用前触发引用动作。"
- "拼接提示统一为XClaw优先使用...’,并在消息区剥离该后缀。"
- "提交态拼接 Skill 标识使用 skill_id不使用 skill 的展示名。"
artifacts:
- path: "frontend/src/components/workspace/artifacts/artifact-file-list.tsx"
provides: "ContextMenu 引用动作改为显式点击触发"
contains: "onClick={() => {"
- path: "frontend/src/core/threads/hooks.ts"
provides: "skill_id 拼接入 submitText"
contains: "skill.skill_id"
- path: "frontend/src/core/messages/utils.ts"
provides: "XClaw 前缀剥离"
contains: "stripPriorityHintSuffix"
---
<objective>
关闭 07-UAT 中 3 个 gapContextMenu 自动引用、拼接前缀不够独特、Skill 使用 title 而非 id。
Purpose: 让提示拼接语义更可追踪,避免误触引用,同时保持 UI 展示与提交 payload 语义解耦。
Output: 修复提交链路与右键引用交互,并补齐回归测试。
</objective>
<tasks>
<task>
<name>Task 1: 修复 ContextMenu 引用误触发</name>
<files>frontend/src/components/workspace/artifacts/artifact-file-list.tsx, frontend/src/components/workspace/messages/message-list-item.tsx</files>
<action>
将“引用”动作从易误触发的 `onSelect` 路径收敛到显式点击触发;确保仅在用户明确选择“引用”菜单项时才 dispatch mention event。
</action>
<acceptance_criteria>
- 右键打开菜单时不会自动触发引用。
- 菜单项点击后才触发引用并回填输入区。
</acceptance_criteria>
<verify>
<automated>rg -n "ContextMenuItem|onSelect|onClick|dispatchMentionReference" frontend/src/components/workspace/artifacts/artifact-file-list.tsx frontend/src/components/workspace/messages/message-list-item.tsx</automated>
</verify>
<done>ContextMenu 引用行为仅由显式用户点击触发,右键打开菜单不再自动引用。</done>
</task>
<task>
<name>Task 2: 拼接前缀改为 XClaw优先使用</name>
<files>frontend/src/core/threads/priority-hint.ts, frontend/src/core/messages/utils.ts, frontend/src/core/threads/hooks.test.ts</files>
<action>
将提示前缀从“优先使用”统一替换为“XClaw优先使用”并同步更新消息区剥离逻辑与单测断言。
</action>
<acceptance_criteria>
- 请求 payload 中出现“XClaw优先使用【...】”。
- 消息区仍不显示该后缀。
- 单测全部通过。
</acceptance_criteria>
<verify>
<automated>rg -n "XClaw优先使用|stripPriorityHintSuffix|composeSubmitText" frontend/src/core/threads/priority-hint.ts frontend/src/core/messages/utils.ts frontend/src/core/threads/hooks.ts</automated>
</verify>
<done>前缀与剥离规则统一为 XClaw 版本,提交态与展示态语义保持一致。</done>
</task>
<task>
<name>Task 3: Skill 提示使用 skill_id</name>
<files>frontend/src/core/threads/hooks.ts, frontend/tests/e2e/input-and-compose.spec.ts</files>
<action>
提交文本组装时将 Skill 输入源改为 `selectedSkills.skill_id`,不要使用 `title`。补充/调整 E2E 断言验证请求体中的 skill_id 出现。
</action>
<acceptance_criteria>
- 拼接中 Skill 部分使用 id 列表。
- 发送按钮与回车路径行为一致。
</acceptance_criteria>
<verify>
<automated>rg -n "selectedSkills|skill_id|composeSubmitText" frontend/src/core/threads/hooks.ts</automated>
<automated>cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"</automated>
</verify>
<done>提交提示中的 Skill 标识稳定使用 skill_id且主要发送入口回归通过。</done>
</task>
</tasks>
<verification>
- `cd frontend && pnpm -s typecheck`
- `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"`
</verification>
<success_criteria>
- 07-UAT 提到的 3 条 gap 在代码和测试层均可回归。
- 形成可直接执行的 gap closure 计划。
</success_criteria>

View File

@ -0,0 +1,59 @@
---
phase: 07-phase-06-mention-upload
plan: 02
subsystem: gap-closure
tags: [context-menu, priority-hint, skill-id, references, e2e]
requires:
- phase: 07-phase-06-mention-upload
provides: 07-01-SUMMARY.md
provides:
- 修复右键打开 ContextMenu 时误触发“引用”的问题
- 优先提示前缀统一为“XClaw优先使用”并与展示层剥离规则对齐
- 提交态 Skill 拼接使用 skill_id避免使用展示名 title
affects: [frontend-chat-input, message-render, thread-submit-payload]
tech-stack:
added: []
patterns:
- explicit-click-only context-menu reference action
- submit/display separation with stable id-based hint composition
key-files:
created:
- .planning/phases/07-phase-06-mention-upload/07-02-SUMMARY.md
modified:
- frontend/src/components/workspace/artifacts/artifact-file-list.tsx
- frontend/src/components/workspace/messages/message-list-item.tsx
- frontend/src/core/threads/priority-hint.ts
- frontend/src/core/messages/utils.ts
- frontend/src/core/threads/hooks.ts
- frontend/src/core/threads/hooks.test.ts
- frontend/tests/e2e/input-and-compose.spec.ts
key-decisions:
- "ContextMenu 引用动作仅绑定显式点击,移除 onSelect 触发路径。"
- "优先提示统一改为 XClaw 前缀,并同步更新消息展示剥离规则。"
- "Skill 拼接数据源统一使用 selectedSkills.skill_id。"
requirements-completed: [P7-01, P7-02, P7-03, P7-04]
duration: 35 min
completed: 2026-04-17
---
# Phase 07 Plan 02 Summary
完成了 Phase 07 的 3 个 UAT gap closure引用误触发、提示前缀唯一化、Skill 提示标识稳定化。
## Implemented
- 将 artifact 列表与消息附件中的 `ContextMenuItem` 引用动作从 `onSelect` 改为 `onClick`,避免仅右键打开菜单就自动引用。
- `priority-hint` 规则升级为 `XClaw优先使用...`并保持“附件在前、Skill 在后、大小写不敏感去重”。
- `stripPriorityHintSuffix` 同步匹配新前缀,确保消息区继续只展示用户原文。
- `hooks.ts` 在两条发送链路中均改为使用 `selectedSkills.skill_id` 参与提交态拼接。
- 单测与 E2E 断言同步更新到新前缀。
## Verification
- `node --test frontend/src/core/threads/hooks.test.ts`7 passed
- `cd frontend && pnpm -s typecheck`passed
- `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A|reference|context menu"`1 passed
## Notes
- 本计划为 `gap_closure: true`,直接对应 `07-UAT.md` 中 3 个已诊断缺口。

View File

@ -0,0 +1,110 @@
# Phase 7: 发送时拼接附件与Skill优先提示词并在消息区过滤 - Context
**Gathered:** 2026-04-17
**Status:** Ready for planning
<domain>
## Phase Boundary
在用户发送消息时,将“附件/引用文件 + 已选 Skill”转换为一段附加指令并拼接到提交给后端的提示词中同时保证消息区仍只展示用户原始输入不展示这段拼接指令。
本阶段不新增新的消息协议主结构,不改变现有 `additional_kwargs.files` 的来源语义,只在发送链路中补充“提交态提示词增强”。
</domain>
<decisions>
## Implementation Decisions
### 拼接文案规则
- **D-01:** 统一使用格式:`优先使用【附件1、附件2】和【Skill1、Skill2】`。
- **D-02:** 仅存在一类时只输出该类(仅附件或仅 Skill两类都为空时不拼接。
- **D-03:** 名称去重后再拼接,顺序固定为“附件 → Skill”。
### 拼接时机与作用域
- **D-04:** 仅在真正提交到后端前拼接,不改输入框内文本。
- **D-05:** 覆盖所有发送入口:发送按钮、回车发送、建议词自动发送。
### 消息区过滤策略
- **D-06:** 采用“提交态增强、展示态原文”策略:
UI 和消息区始终使用用户原文;仅请求 payload 使用“原文 + 拼接文案”。
- **D-07:** 不采用渲染层二次过滤(避免把拼接后文本写入消息主内容)。
### 数据来源与去重口径
- **D-08:** 附件名使用最终提交文件名(`references + uploads` 汇总后的文件名)。
- **D-09:** Skill 名使用当前选中 Skill tag 的 `title`
- **D-10:** 去重采用大小写不敏感规则。
### the agent's Discretion
- 拼接文案中附件与 Skill 的最大展示数量若过长时是否截断与“等N项”策略
- “名称标准化”细节(如首尾空白裁剪、重复空格折叠)。
- 内部 helper 命名与模块拆分方式(前提是不改变已锁定行为)。
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### 阶段边界与既有决策
- `.planning/ROADMAP.md` — Phase 7 条目与边界(发送时拼接 + 消息区不显示)。
- `.planning/STATE.md` — 当前里程碑状态与 Phase 7 演进记录。
- `.planning/PROJECT.md` — 核心原则:保持现有体验并稳定新系统行为。
- `.planning/REQUIREMENTS.md` — 现有约束基线(特别是稳定性与回归要求)。
- `.planning/phases/06-/06-CONTEXT.md` — Phase 6 已锁定的文件引用/提交语义(`additional_kwargs.files`)。
### 发送链路与输入框集成点
- `frontend/src/components/workspace/input-box.tsx` — 输入框提交入口(`handleSubmit`)与 references/selectedSkills 来源。
- `frontend/src/app/workspace/chats/[thread_id]/page.tsx` — 页面级 `handleSubmit``sendMessage` 的调用边界。
- `frontend/src/core/threads/hooks.ts` — 实际提交到线程流的发送逻辑payload 组装主入口)。
- `frontend/src/components/ai-elements/prompt-input.tsx``PromptInputMessage` 结构与表单提交机制。
### 消息展示与文件渲染链路
- `frontend/src/components/workspace/messages/message-list-item.tsx` — 人类消息展示内容与附件列表渲染逻辑。
- `frontend/src/core/threads/submit-files.ts` — references/uploads 汇总为 `additional_kwargs.files` 的归一化逻辑。
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `InputBox.handleSubmit` 已是发送前最后一层前端聚合点,可在此构建“提交态增强文案”。
- `useThreadStream.sendMessage` 已集中处理 payload 发送,可作为最终拼接注入点。
- `PromptInputMessage``message.references` 已具备附件/引用上下文,不需要新增输入结构。
- `useIframeSkill` 暴露 `selectedSkills`(含 `title`),可直接提供 Skill 名来源。
### Established Patterns
- 文件信息通过 `additional_kwargs.files` 单一 envelope 传递,消息正文与文件元数据分离。
- 人类消息展示默认使用 `rawContent`(并对 `<uploaded_files>` 标签做兼容剥离),适合维持“展示态原文”。
- 错误处理采用软失败 + toast不阻断主发送链路。
### Integration Points
- 入口:`input-box.tsx` 的 `handleSubmit`拿到原文、references、selectedSkills
- 提交:`core/threads/hooks.ts` 的 `sendMessage`(对后端 payload 的最终写入点)。
- 展示:`message-list-item.tsx`(保持仅展示用户原文,不反显拼接提示)。
</code_context>
<specifics>
## Specific Ideas
- 拼接模板固定为:`优先使用【附件...】和【Skill...】`并按“附件→Skill”顺序输出。
- 覆盖建议词自动发送路径,避免不同发送入口行为不一致。
- 消息区不做“后置过滤黑科技”,而是从源头保证展示内容就是原文。
</specifics>
<deferred>
## Deferred Ideas
- 按模型能力动态调整拼接策略(如不同模型使用不同提示语模板)。
- 将“优先使用”文案国际化为多语言可配置模板。
- 在 UI 中显式展示“将附加系统提示”的可见开关。
</deferred>
---
*Phase: 07-phase-06-mention-upload*
*Context gathered: 2026-04-17*

View File

@ -0,0 +1,74 @@
# Phase 7: 发送时拼接附件与Skill优先提示词并在消息区过滤 - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-04-17T02:42:19Z
**Phase:** 07-phase-06-mention-upload
**Areas discussed:** 拼接文案规则, 拼接时机与作用域, 消息区过滤策略, 数据来源与去重口径
---
## 拼接文案规则
| Option | Description | Selected |
|--------|-------------|----------|
| A | `优先使用【附件1、附件2】和【Skill1、Skill2】`;单类单出;去重;附件优先 | ✓ |
| B | 自然语言长句,不固定括号模板 | |
| C | 用户自定义格式 | |
**User's choice:** A
**Notes:** 用户要求固定格式,确保输出稳定可预测。
---
## 拼接时机与作用域
| Option | Description | Selected |
|--------|-------------|----------|
| A | 真正提交到后端前拼接;覆盖按钮/回车/建议词自动发送 | ✓ |
| B | 仅覆盖手动发送(按钮/回车) | |
| C | 更细粒度范围 | |
**User's choice:** A
**Notes:** 目标是所有发送入口行为一致,不留分叉路径。
---
## 消息区过滤策略
| Option | Description | Selected |
|--------|-------------|----------|
| A | UI/消息区始终原文;仅 payload 为“原文+拼接文案” | ✓ |
| B | 存拼接后文本,再在渲染层过滤 | |
| C | 自定义实现 | |
**User's choice:** A
**Notes:** 明确不要把拼接内容展示在消息区,避免渲染层补丁方案。
---
## 数据来源与去重口径
| Option | Description | Selected |
|--------|-------------|----------|
| A | 附件名取最终提交文件名Skill 名取选中 tag 的 `title`;大小写不敏感去重 | ✓ |
| B | 附件优先引用名Skill 取 suggestion 名 | |
| C | 自定义口径 | |
**User's choice:** A
**Notes:** 以“最终提交数据”作为一致源,减少多来源命名歧义。
---
## the agent's Discretion
- 长列表展示截断策略(是否 `等N项`)。
- 名称标准化细节trim/空白折叠)。
- helper 拆分与命名。
## Deferred Ideas
- 拼接模板国际化
- 用户可视化开关(是否附加“优先使用”提示)
- 按模型动态提示模板

View File

@ -0,0 +1,59 @@
---
phase: 07
slug: phase-06-mention-upload
status: verified
threats_open: 0
asvs_level: 1
created: 2026-04-17
---
# Phase 07 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| 输入框展示态 -> 提交态 payload | 同一条用户消息在展示与提交存在双态,需防止内部提示文案泄露到用户可见区 | 用户原文、拼接提示文本、附件/Skill 标识 |
| 前端组装器 -> 后端存档消息 | 拼接文案进入提交链路并可能回流,需要保证展示层过滤与提交层分离 | 提交消息正文、`additional_kwargs.files`、历史消息渲染内容 |
---
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| T-07-01 | I (Information Disclosure) | `frontend/src/core/threads/hooks.ts` + `frontend/src/components/workspace/messages/message-list-item.tsx` | mitigate | 提交态使用 `submitText`,展示态经 `stripPriorityHintSuffix` 过滤E2E 验证消息区不回显优先提示 | closed |
| T-07-02 | T (Tampering / flow bypass) | `frontend/src/components/workspace/input-box.tsx` | mitigate | 发送入口统一经 `requestSubmit -> handleSubmit` 透传 references/skills避免分支漏传 | closed |
| T-07-03 | R (Repudiation / traceability) | `frontend/tests/e2e/input-and-compose.spec.ts` | mitigate | 增加请求拦截断言DF-INPUT-008A可审计提交内容含 `XClaw优先使用` 且 UI 不显示后缀 | closed |
*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
No accepted risks.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-04-17 | 3 | 3 | 0 | Codex (`/gsd-secure-phase 7`) |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-04-17

View File

@ -0,0 +1,40 @@
---
status: complete
phase: 07-phase-06-mention-upload
source:
- 07-01-SUMMARY.md
- 07-02-SUMMARY.md
started: 2026-04-17T05:32:48Z
updated: 2026-04-17T05:43:13Z
---
## Current Test
[testing complete]
## Tests
### 1. ContextMenu 引用仅在显式点击时触发
expected: 在消息附件或 artifact 文件上执行右键时,仅打开 ContextMenu不会自动触发引用仅点击“引用”后才新增引用 chip。
result: pass
### 2. 提交态拼接 XClaw 前缀且消息区不回显
expected: 选择附件/引用并发送后请求提交内容包含“XClaw优先使用【...】”;消息区仅显示用户原文,不显示该提示后缀。
result: pass
### 3. Skill 拼接使用 skill_id 且发送入口行为一致
expected: 点击发送与回车发送遵循同一拼接规则Skill 部分使用 skill_id不是 title点击建议词仅填充输入或触发 skill且不自动发送。
result: pass
## Summary
total: 3
passed: 3
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[none yet]

View File

@ -0,0 +1,84 @@
---
phase: 07
slug: phase-06-mention-upload
status: verified
nyquist_compliant: true
wave_0_complete: true
created: 2026-04-17
---
# Phase 07 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Vitest + Playwrightfrontend |
| **Config file** | `frontend/vitest.config.ts`, `frontend/playwright.config.ts` |
| **Quick run command** | `cd frontend && pnpm -s test -- --run src/core/threads` |
| **Full suite command** | `cd frontend && pnpm -s lint && pnpm -s typecheck && pnpm -s test:e2e --grep "input|compose|mention"` |
| **Estimated runtime** | ~240 seconds |
---
## Sampling Rate
- **After every task commit:** Run `cd frontend && pnpm -s test -- --run src/core/threads`
- **After every plan wave:** Run `cd frontend && pnpm -s lint && pnpm -s typecheck`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 300 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 07-01-01 | 01 | 1 | P7-01, P7-02 | T-07-01 | 发送前拼接且消息区不回显拼接文案 | unit + e2e | `node --test frontend/src/core/threads/hooks.test.ts` + `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-008A"` | ✅ | ✅ green |
| 07-01-02 | 01 | 1 | P7-03 | T-07-02 | 附件/Skill 名来源、顺序与去重规则一致 | unit | `node --test frontend/src/core/threads/hooks.test.ts` | ✅ | ✅ green |
| 07-01-03 | 01 | 1 | P7-04 | T-07-03 | 所有发送入口行为一致,不出现分叉 | e2e | `cd frontend && pnpm -s test:e2e --grep "DF-INPUT-003|DF-INPUT-005|DF-INPUT-008A"` | ✅ | ✅ green |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [x] `frontend/src/core/threads/hooks.test.ts` — 已覆盖提交态增强文本、顺序与去重断言
- [x] `frontend/src/components/workspace/input-box.test.tsx` — 由 E2E 发送入口链路覆盖,无独立缺口
- [x] `frontend/tests/e2e/input-and-compose.spec.ts` — 已包含“消息区不显示拼接文案”回归DF-INPUT-008A
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| 多语言文案下拼接语句可读性 | P7-01 | 文案自然性主观 | 在中文/英文 UI 下分别发送含附件+Skill消息人工检查生成文本 |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 300s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** verified 2026-04-17
---
## Validation Audit 2026-04-17
| Metric | Count |
|--------|-------|
| Gaps found | 0 |
| Resolved | 0 |
| Escalated | 0 |

View File

@ -0,0 +1,40 @@
# Quick Task 260417-kcb: suggestion hover dropdown + child tooltip + bootstrap ids/sessionStorage - Context
**Gathered:** 2026-04-17
**Status:** Ready for planning
<domain>
## Task Boundary
实现 suggestion 多字内容展示:
- 悬浮 suggestion 显示 dropdown内容来自 children
- 悬浮 dropdown 菜单项显示 tooltip内容来自 detail
- 点击 suggestion将 children 的 skill id 组合为数组并发送 bootstrap
- 点击 dropdown 菜单项:仅发送当前 id 进行 bootstrap
- 发起 bootstrap 时同步更新 sessionStorage
</domain>
<decisions>
## Implementation Decisions
### Dropdown Trigger
- 使用 suggestion hover 打开 dropdown同时保留 suggestion click 行为。
### Tooltip Source
- tooltip 文案优先使用 children.detail无 detail 时回退到 skill name。
### Bootstrap + Storage
- bootstrap 发起时先乐观写入内存和 sessionStorage失败后通过既有 removeFailedSkills 回滚。
### the agent's Discretion
- 不变更现有 API 入参与消息结构语义,仅扩展可选字段与前端交互。
</decisions>
<specifics>
## Specific Ideas
新增 children.detail 可选属性以支撑菜单项 tooltip。
</specifics>

View File

@ -0,0 +1,37 @@
---
mode: quick-full
must_haves:
truths:
- suggestion hover 能看到 children dropdown
- dropdown 菜单项 hover 能看到 detail tooltip
- suggestion click 发送 children 全量 id bootstrap
- dropdown item click 仅发送当前 id bootstrap
- bootstrap 发起时 sessionStorage 立即同步,失败可回滚
artifacts:
- frontend/src/components/workspace/input-box.tsx
- frontend/src/hooks/use-iframe-skill.ts
- frontend/src/core/i18n/locales/types.ts
- frontend/src/core/iframe-messages.ts
- frontend/src/core/i18n/locales/zh-CN.ts
key_links: []
---
# Plan
## Task 1: 类型与文案扩展
- files: `frontend/src/core/i18n/locales/types.ts`, `frontend/src/core/iframe-messages.ts`, `frontend/src/core/i18n/locales/zh-CN.ts`
- action: 为 children 增加 `detail` 可选字段并补充中文建议数据。
- verify: TypeScript 通过i18n suggestions 可读取 detail。
- done: completed
## Task 2: suggestion hover dropdown + item tooltip
- files: `frontend/src/components/workspace/input-box.tsx`
- action: 引入 hover 打开 dropdown菜单项展示 childrentooltip 展示 detail。
- verify: 交互符合需求,主 suggestion 与子项点击行为分离。
- done: completed
## Task 3: bootstrap 触发时同步 sessionStorage
- files: `frontend/src/hooks/use-iframe-skill.ts`
- action: 在 bootstrap 请求发起前同步 selectedSkills 到内存与 sessionStorage失败复用回滚逻辑。
- verify: `pnpm -s typecheck` 通过。
- done: completed

View File

@ -0,0 +1,12 @@
# Quick Task 260417-kcb - Research
## Findings
1. 当前 `SuggestionList` 已具备 children 优先 bootstrap 机制,改造点集中在 UI 展示和事件分流。
2. 代码库已有 Radix `DropdownMenu``Tooltip` 封装,直接复用可最小化风险。
3. `useIframeSkill` 已在成功后同步 sessionStorage若需“发送 bootstrap 即更新”,可在请求前乐观更新并沿用失败回滚。
## Integration Notes
- 受影响文件:`input-box.tsx`、`use-iframe-skill.ts`、i18n 类型与 zh-CN 文案。
- 不涉及后端接口变更。

View File

@ -0,0 +1,15 @@
# Quick Task 260417-kcb - Summary
## Delivered
- 新增 `detail` 可选属性并在中文 suggestions 中补充 detail 文案。
- suggestion 悬浮时可见 dropdown内容来自 children。
- dropdown 菜单项悬浮时显示 tooltip内容来自 detail无 detail 回退 name
- 点击 suggestionbootstrap 使用 children 的全部 id。
- 点击 dropdown 菜单项bootstrap 仅携带该菜单项 id。
- bootstrap 发起时即更新 sessionStorage 与本地 selectedSkills失败走回滚。
## Validation
- `cd frontend && pnpm -s typecheck` passed
- `cd frontend && pnpm -s lint` failed仓库已有历史 lint 问题,非本次引入)

View File

@ -0,0 +1,14 @@
status: passed
# Verification
- [x] suggestion hover shows dropdown children
- [x] dropdown item hover shows tooltip(detail)
- [x] suggestion click bootstraps all child ids
- [x] dropdown item click bootstraps only selected id
- [x] bootstrap start updates sessionStorage
- [x] TypeScript check passed
## Residual Risk
- 未在本地启动浏览器进行可视化手动回归;建议在真实页面快速 smoke test 一次。

View File

@ -0,0 +1,200 @@
---
milestone: v1.0
audited: 2026-04-17T06:05:06Z
status: gaps_found
scores:
requirements: 6/17
phases: 2/7
integration: 1/1
flows: 0/2
gaps:
requirements:
- id: "MERGE-02"
status: "orphaned"
phase: "Phase 1"
claimed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-PLAN.md"]
completed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-SUMMARY.md"]
verification_status: "orphaned"
evidence: "Listed in SUMMARY frontmatter, but absent from all phase VERIFICATION.md files (only 01 and 06 verification files exist)."
- id: "LOGIC-03"
status: "orphaned"
phase: "Phase 2"
claimed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-PLAN.md"]
completed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-SUMMARY.md"]
verification_status: "orphaned"
evidence: "Traceability marks complete, but no phase VERIFICATION coverage; integration audit also flags xclaw_used compatibility gap."
- id: "LOGIC-04"
status: "orphaned"
phase: "Phase 2"
claimed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-PLAN.md"]
completed_by_plans: [".planning/phases/02-thread-and-skills-logic-reconciliation/02-SUMMARY.md"]
verification_status: "orphaned"
evidence: "Claimed in SUMMARY, absent from all VERIFICATION.md; integration audit flags legacy content_id adapter risk."
- id: "UI-01"
status: "orphaned"
phase: "Phase 3"
claimed_by_plans: [".planning/phases/03-legacy-visual-alignment-pass/03-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Not listed in requirements-completed frontmatter and no phase VERIFICATION.md exists for Phase 3."
- id: "UI-02"
status: "orphaned"
phase: "Phase 3"
claimed_by_plans: [".planning/phases/03-legacy-visual-alignment-pass/03-PLAN.md", ".planning/phases/03-legacy-visual-alignment-pass/03-02-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Mentioned as targeted in summaries but not in requirements-completed frontmatter and no VERIFICATION.md exists."
- id: "UI-03"
status: "orphaned"
phase: "Phase 3"
claimed_by_plans: [".planning/phases/03-legacy-visual-alignment-pass/03-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "No requirements-completed frontmatter evidence and no phase VERIFICATION.md exists."
- id: "LOGIC-01"
status: "orphaned"
phase: "Phase 4"
claimed_by_plans: [".planning/phases/04-iframe-markdown-new-system-stabilization/04-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Only targeted in summary body; no requirements-completed frontmatter and no phase VERIFICATION.md exists."
- id: "LOGIC-02"
status: "orphaned"
phase: "Phase 4"
claimed_by_plans: [".planning/phases/04-iframe-markdown-new-system-stabilization/04-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Only targeted in summary body; no requirements-completed frontmatter and no phase VERIFICATION.md exists."
- id: "TEST-01"
status: "orphaned"
phase: "Phase 5"
claimed_by_plans: [".planning/phases/05-test-hardening-and-commit-hygiene/05-PLAN.md", ".planning/phases/03-legacy-visual-alignment-pass/03-02-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "Targeted in summary text but not requirements-completed frontmatter and no phase VERIFICATION.md exists."
- id: "TEST-02"
status: "orphaned"
phase: "Phase 5"
claimed_by_plans: [".planning/phases/05-test-hardening-and-commit-hygiene/05-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "No phase VERIFICATION.md exists for Phase 5; traceability still pending."
- id: "TEST-03"
status: "orphaned"
phase: "Phase 5"
claimed_by_plans: [".planning/phases/05-test-hardening-and-commit-hygiene/05-PLAN.md"]
completed_by_plans: []
verification_status: "orphaned"
evidence: "No phase VERIFICATION.md exists for Phase 5; integration audit additionally flags missing 07-VERIFICATION as auditability gap."
integration:
- from: "Phase 2"
to: "Phase 2/7 runtime"
issue: "LOGIC-03 requires xclaw_used handling, but runtime consumer is not present in code path."
- from: "Phase 2"
to: "Phase 4/7 runtime"
issue: "Legacy content_id adapter evidence is incomplete; content_ids-only flow may not satisfy LOGIC-04 compatibility claim."
flows:
- name: "Legacy compatibility flow (thread_id/isnew/xclaw_used)"
break_at: "xclaw_used ingestion/propagation"
evidence: "No code-path consumer found; flagged by integration checker."
- name: "Verification evidence flow"
break_at: "Phase verification artifact generation"
evidence: "Phases 02/03/04/05/07 are missing *-VERIFICATION.md."
tech_debt:
- phase: "02-thread-and-skills-logic-reconciliation"
items:
- "E2E was environment-blocked during summary run (ERR_CONNECTION_REFUSED at 127.0.0.1:2026)."
- "Summary/code drift noted for referenced files in integration audit."
- phase: "03-legacy-visual-alignment-pass"
items:
- "Execution relied on merged dirty baseline with blockers deferred across phases."
- phase: "04-iframe-markdown-new-system-stabilization"
items:
- "5 E2E skips recorded for fixture/history-dependent paths."
- phase: "05-test-hardening-and-commit-hygiene"
items:
- "10 E2E skips remain, explained but still deferred reliability debt."
- phase: "06-"
items:
- "06-VALIDATION.md status is draft despite nyquist_compliant true."
- phase: "07-phase-06-mention-upload"
items:
- "07-VALIDATION exists without 07-VERIFICATION artifact."
nyquist:
compliant_phases: ["06", "07"]
partial_phases: []
missing_phases: ["01", "02", "03", "04", "05"]
overall: "partial"
---
# Milestone v1.0 Audit
## Scope
- Milestone: `v1.0`
- In-scope phase directories:
- `.planning/phases/01-conflict-inventory-and-decision-matrix`
- `.planning/phases/02-thread-and-skills-logic-reconciliation`
- `.planning/phases/03-legacy-visual-alignment-pass`
- `.planning/phases/04-iframe-markdown-new-system-stabilization`
- `.planning/phases/05-test-hardening-and-commit-hygiene`
- `.planning/phases/06-`
- `.planning/phases/07-phase-06-mention-upload`
## Phase Verification Coverage
| Phase | VERIFICATION.md | Status |
|---|---|---|
| 01 | present | passed |
| 02 | missing | unverified (blocker) |
| 03 | missing | unverified (blocker) |
| 04 | missing | unverified (blocker) |
| 05 | missing | unverified (blocker) |
| 06 | present | passed |
| 07 | missing | unverified (blocker) |
## Requirements 3-Source Cross-Reference
| REQ-ID | Traceability | VERIFICATION Source | SUMMARY `requirements-completed` | Final |
|---|---|---|---|---|
| MERGE-01 | Complete | passed (01) | listed | satisfied |
| MERGE-02 | Complete | missing/orphaned | listed | unsatisfied (orphaned) |
| MERGE-03 | Complete | passed (01) | listed | satisfied |
| LOGIC-03 | Complete | missing/orphaned | listed | unsatisfied (orphaned) |
| LOGIC-04 | Complete | missing/orphaned | listed | unsatisfied (orphaned) |
| UI-01 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| UI-02 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| UI-03 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| LOGIC-01 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| LOGIC-02 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| TEST-01 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| TEST-02 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| TEST-03 | Pending | missing/orphaned | missing | unsatisfied (orphaned) |
| ATREF-01 | Pending | passed (06) | listed | satisfied (checkbox stale) |
| ATREF-02 | Pending | passed (06) | listed | satisfied (checkbox stale) |
| ATREF-03 | Pending | passed (06) | listed | satisfied (checkbox stale) |
| ATREF-04 | Pending | passed (06) | listed | satisfied (checkbox stale) |
### FAIL Gate
`gaps_found` is enforced because unsatisfied requirements exist (11), including orphaned requirements assigned in traceability but absent from all phase VERIFICATION files.
## Integration Checker Results
### Critical
- No critical integration break found across phases 2 to 7.
### Non-Critical
- LOGIC-03 compatibility gap (`xclaw_used` path not evidenced in runtime).
- LOGIC-04 compatibility risk (legacy adapter evidence incomplete).
- Phase 2 summary/code artifact drift.
- Phase 7 has validation but no verification artifact.
## Broken Flows
- Legacy compatibility flow (`thread_id/isnew/xclaw_used`) breaks at xclaw_used ingestion/propagation.
- Verification evidence flow breaks at missing phase-level VERIFICATION artifacts.
## Overall Conclusion
Milestone `v1.0` is **not ready to complete** under current audit gates. Requirements and integration implementation are substantial, but verification artifacts are incomplete for multiple phases, causing orphaned requirements and mandatory `gaps_found` status.

View File

@ -1,5 +1,7 @@
import logging import logging
import mimetypes import mimetypes
import re
import unicodedata
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -19,6 +21,9 @@ ACTIVE_CONTENT_MIME_TYPES = {
"image/svg+xml", "image/svg+xml",
} }
_DASH_VARIANTS_RE = re.compile(r"\s*[-\u2010\u2011\u2012\u2013\u2014\u2212]\s*")
_WHITESPACE_RE = re.compile(r"\s+")
def _build_content_disposition(disposition_type: str, filename: str) -> str: def _build_content_disposition(disposition_type: str, filename: str) -> str:
"""Build an RFC 5987 encoded Content-Disposition header value.""" """Build an RFC 5987 encoded Content-Disposition header value."""
@ -32,6 +37,31 @@ def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | Non
return headers return headers
def _canonicalize_filename_for_lookup(filename: str) -> str:
"""Canonical form used for conservative compatibility lookup."""
normalized = unicodedata.normalize("NFKC", filename).strip()
normalized = _DASH_VARIANTS_RE.sub("-", normalized)
normalized = _WHITESPACE_RE.sub(" ", normalized)
return normalized
def _find_compat_filename_match(missing_path: Path) -> Path | None:
"""Find a same-directory file whose canonicalized name uniquely matches."""
parent = missing_path.parent
if not parent.is_dir():
return None
target_name = _canonicalize_filename_for_lookup(missing_path.name)
matches: list[Path] = []
for candidate in parent.iterdir():
if not candidate.is_file():
continue
if _canonicalize_filename_for_lookup(candidate.name) == target_name:
matches.append(candidate)
return matches[0] if len(matches) == 1 else None
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes.""" """Check if file is text by examining content for null bytes."""
try: try:
@ -157,7 +187,15 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}") logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
if not actual_path.exists(): if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}") compat_path = _find_compat_filename_match(actual_path)
if compat_path is None:
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
logger.info(
"Artifact compatibility fallback applied: requested_path=%s, resolved_path=%s",
actual_path,
compat_path,
)
actual_path = compat_path
if not actual_path.is_file(): if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}") raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")

View File

@ -56,6 +56,11 @@ def _normalize_presented_filepath(
except ValueError as exc: except ValueError as exc:
raise ValueError(f"Only files in {OUTPUTS_VIRTUAL_PREFIX} can be presented: {filepath}") from exc raise ValueError(f"Only files in {OUTPUTS_VIRTUAL_PREFIX} can be presented: {filepath}") from exc
if not actual_path.exists():
raise ValueError(f"File does not exist: {filepath}")
if not actual_path.is_file():
raise ValueError(f"Path is not a file: {filepath}")
return f"{OUTPUTS_VIRTUAL_PREFIX}/{relative_path.as_posix()}" return f"{OUTPUTS_VIRTUAL_PREFIX}/{relative_path.as_posix()}"

View File

@ -117,3 +117,16 @@ def test_get_artifact_pdf_with_no_null_bytes_and_non_utf8_content_is_served_inli
assert bytes(response.body) == binary_content assert bytes(response.body) == binary_content
assert response.media_type == "application/pdf" assert response.media_type == "application/pdf"
assert response.headers.get("content-disposition", "").startswith("inline;") assert response.headers.get("content-disposition", "").startswith("inline;")
def test_get_artifact_compat_fallback_for_dash_spacing(tmp_path, monkeypatch) -> None:
artifact_path = tmp_path / "xhs-note-唯-疲劳端茶.md"
artifact_path.write_text("ok", encoding="utf-8")
requested_path = tmp_path / "xhs-note-唯 - 疲劳端茶.md"
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: requested_path)
response = asyncio.run(artifacts_router.get_artifact("thread-1", "mnt/user-data/outputs/xhs-note-唯 - 疲劳端茶.md", _make_request()))
assert bytes(response.body).decode("utf-8") == "ok"
assert response.media_type == "text/markdown"

View File

@ -66,3 +66,18 @@ def test_present_files_rejects_paths_outside_outputs(tmp_path):
assert "artifacts" not in result.update assert "artifacts" not in result.update
assert result.update["messages"][0].content == f"Error: Only files in /mnt/user-data/outputs can be presented: {leaked_path}" assert result.update["messages"][0].content == f"Error: Only files in /mnt/user-data/outputs can be presented: {leaked_path}"
def test_present_files_rejects_nonexistent_file_in_outputs(tmp_path):
outputs_dir = tmp_path / "threads" / "thread-1" / "user-data" / "outputs"
outputs_dir.mkdir(parents=True)
missing_path = outputs_dir / "missing.md"
result = present_file_tool_module.present_file_tool.func(
runtime=_make_runtime(str(outputs_dir)),
filepaths=[str(missing_path)],
tool_call_id="tc-4",
)
assert "artifacts" not in result.update
assert result.update["messages"][0].content == f"Error: File does not exist: {missing_path}"

View File

@ -479,6 +479,12 @@ export type PromptInputMessage = {
text: string; text: string;
files: FileUIPart[]; files: FileUIPart[];
references?: PromptInputReference[]; references?: PromptInputReference[];
selectedSkills?: PromptInputSkill[];
};
export type PromptInputSkill = {
skill_id: string;
title: string;
}; };
export type PromptInputReference = { export type PromptInputReference = {

View File

@ -102,7 +102,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
data-slot="context-menu-content" data-slot="context-menu-content"
className={cn( className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-0 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className className
)} )}
{...props} {...props}

View File

@ -44,7 +44,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[20px] border p-[20px] shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[20px] border p-[20px] shadow-[0_0_20px_0_rgba(0,0,0,0.20)]",
className, className,
)} )}
{...props} {...props}

View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -7,7 +7,7 @@ function Tag({ className, ...props }: React.ComponentProps<"span">) {
<span <span
data-slot="tag" data-slot="tag"
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-full border border-transparent bg-[#EAE2F5] px-[15px] py-[4px] text-xs font-medium text-[#8E47F0]", "inline-flex items-center gap-1 rounded-full border border-transparent bg-[#EAE2F5] px-[15px] py-[5px] text-xs font-medium text-[#8E47F0]",
className, className,
)} )}
{...props} {...props}

View File

@ -34,6 +34,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { DropdownSelector } from "@/components/ui/dropdown-selector"; import { DropdownSelector } from "@/components/ui/dropdown-selector";
import { Slider } from "@/components/ui/slider";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
@ -457,10 +458,11 @@ export function ArtifactFileDetail({
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} )}
{/* 仅在代码视图显示缩放控制 */} {/* 代码视图显示缩放控制Markdown 预览也显示缩放控制 */}
{isCodeFile && viewMode === "code" && ( {(isCodeFile && viewMode === "code") ||
(language === "markdown" && viewMode === "preview") ? (
<ArtifactZoomSelector value={zoom} onChange={setZoom} /> <ArtifactZoomSelector value={zoom} onChange={setZoom} />
)} ) : null}
</div> </div>
<div className="col-span-6 flex min-w-0 items-center justify-center px-1"> <div className="col-span-6 flex min-w-0 items-center justify-center px-1">
<ArtifactTitle> <ArtifactTitle>
@ -1652,106 +1654,75 @@ export const ArtifactZoomSelector = ({
...props ...props
}: ArtifactZoomSelectorProps) => { }: ArtifactZoomSelectorProps) => {
const { t } = useI18n(); const { t } = useI18n();
const handleZoomIn = () => { const resolvedIndex = useMemo(() => {
const currentIndex = ZOOM_LEVELS.indexOf(value); const exactIndex = ZOOM_LEVELS.indexOf(value);
const nextValue = ZOOM_LEVELS[currentIndex + 1]; if (exactIndex >= 0) return exactIndex;
if (currentIndex < ZOOM_LEVELS.length - 1 && nextValue !== undefined) { let nearestIndex = 0;
onChange?.(nextValue); let nearestDistance = Number.POSITIVE_INFINITY;
} ZOOM_LEVELS.forEach((level, index) => {
}; const distance = Math.abs(level - value);
if (distance < nearestDistance) {
const handleZoomOut = () => { nearestDistance = distance;
const currentIndex = ZOOM_LEVELS.indexOf(value); nearestIndex = index;
const prevValue = ZOOM_LEVELS[currentIndex - 1]; }
if (currentIndex > 0 && prevValue !== undefined) { });
onChange?.(prevValue); return nearestIndex;
} }, [value]);
};
const canZoomIn = ZOOM_LEVELS.indexOf(value) < ZOOM_LEVELS.length - 1;
const canZoomOut = ZOOM_LEVELS.indexOf(value) > 0;
return ( return (
<div <div className={cn("inline-flex", className)} {...props}>
className={cn( <DropdownMenu>
"bg-background border-border inline-flex h-[28px] items-center gap-1 rounded-[10px] border backdrop-blur-sm", <DropdownMenuTrigger asChild>
"dark:border-border dark:bg-background", <button
className, type="button"
)} aria-label={t.artifactPreview.zoomIn}
{...props} className={cn(
> "bg-background border-border text-muted-foreground hover:text-foreground inline-flex h-[28px] w-[28px] items-center justify-center rounded-[10px] border transition-colors",
<button "hover:bg-muted/60",
type="button" )}
onClick={handleZoomIn} >
disabled={!canZoomIn} <svg
className={cn( xmlns="http://www.w3.org/2000/svg"
"flex h-full w-10 items-center justify-center rounded py-1 transition-colors", width="16"
"text-muted-foreground hover:bg-muted hover:text-foreground", height="16"
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent", viewBox="0 0 16 16"
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground", fill="none"
)} >
aria-label={t.artifactPreview.zoomIn} <circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
> <path
<svg d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
xmlns="http://www.w3.org/2000/svg" fill="#666666"
width="16" />
height="16" <path
viewBox="0 0 16 16" d="M5.33325 7.5H9.7777M7.55547 5V10"
fill="none" stroke="#666666"
> strokeLinecap="round"
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" /> strokeLinejoin="round"
<path />
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z" </svg>
fill="#666666" </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-52 p-[20px] ">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{ZOOM_LEVELS[0]}%
</span>
<span className="text-foreground text-xs font-medium">{value}%</span>
</div>
<Slider
min={0}
max={ZOOM_LEVELS.length - 1}
step={1}
value={[resolvedIndex]}
onValueChange={(values) => {
const nextIndex = values[0];
if (nextIndex === undefined) return;
const nextValue = ZOOM_LEVELS[nextIndex];
if (nextValue !== undefined) onChange?.(nextValue);
}}
/> />
<path </DropdownMenuContent>
d="M5.33325 7.5H9.7777M7.55547 5V10" </DropdownMenu>
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<span
className={cn(
"text-foreground min-w-[36px] text-center text-xs font-medium",
"dark:text-foreground",
)}
>
{value}%
</span>
<button
type="button"
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-full w-10 items-center justify-center rounded transition-colors",
"text-muted-foreground hover:bg-muted hover:text-foreground",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
)}
aria-label={t.artifactPreview.zoomOut}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
<path
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
fill="#666666"
/>
<path
d="M4.99927 7.5H9.99927"
stroke="#666666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div> </div>
); );
}; };

View File

@ -150,9 +150,9 @@ export function ArtifactFileList({
</CardHeader> </CardHeader>
</Card> </Card>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="min-w-[120px] p-1"> <ContextMenuContent className="min-w-[120px]">
<ContextMenuItem <ContextMenuItem
onSelect={() => { onClick={() => {
dispatchMentionReference({ dispatchMentionReference({
threadId, threadId,
filename: getFileName(file), filename: getFileName(file),

View File

@ -368,10 +368,11 @@ export function InputBox({
onSubmit?.({ onSubmit?.({
...message, ...message,
references, references,
selectedSkills: iframeSkill.selectedSkills,
}); });
setReferences([]); setReferences([]);
}, },
[showWelcomeStyle, onSubmit, onStop, references, status], [showWelcomeStyle, onSubmit, onStop, references, status, iframeSkill.selectedSkills],
); );
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {
@ -678,7 +679,7 @@ export function InputBox({
<button <button
ref={mentionTriggerRef} ref={mentionTriggerRef}
type="button" type="button"
className="pointer-events-none absolute right-2 bottom-2 h-0 w-0 opacity-0" className="pointer-events-none absolute right-2 bottom-0 h-0 w-0 opacity-0"
aria-hidden="true" aria-hidden="true"
tabIndex={-1} tabIndex={-1}
/> />
@ -687,7 +688,7 @@ export function InputBox({
align="start" align="start"
side="top" side="top"
sideOffset={8} sideOffset={8}
className="w-[min(32rem,var(--radix-dropdown-menu-trigger-width)+28rem)] max-h-[578px] overflow-y-visible p-[20px]" className="w-[min(32rem,var(--radix-dropdown-menu-trigger-width)+28rem)] max-h-[400px] overflow-y-visible p-[20px]"
data-testid="mention-candidate-panel" data-testid="mention-candidate-panel"
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
event.preventDefault(); event.preventDefault();
@ -936,6 +937,7 @@ function SuggestionList({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
const suggestions = t.inputBox.suggestions; const suggestions = t.inputBox.suggestions;
const promptSuggestions = suggestions.filter( const promptSuggestions = suggestions.filter(
( (
@ -1001,18 +1003,114 @@ function SuggestionList({
}, },
[bootstrapAndLockSkills, isBootstrapping, textInput], [bootstrapAndLockSkills, isBootstrapping, textInput],
); );
const handleSuggestionChildClick = useCallback(
({
childSkill,
suggestionTitle,
}: {
childSkill: SelectedSkillPayloadItem;
suggestionTitle: string;
}) => {
if (isBootstrapping) return;
setOpenDropdownKey(null);
const id = String(childSkill.id).trim();
const name = childSkill.name?.trim() ?? "";
if (!id || !name) return;
void bootstrapAndLockSkills({
selectedSkills: [{ id, name, detail: childSkill.detail }],
title: suggestionTitle,
});
},
[bootstrapAndLockSkills, isBootstrapping],
);
return ( return (
<Suggestions <Suggestions
className="min-h-16 w-fit items-start" className="min-h-16 w-fit items-start"
data-testid="welcome-suggestions" data-testid="welcome-suggestions"
> >
{promptSuggestions.map((suggestion) => ( {promptSuggestions.map((suggestion) => (
<Suggestion (() => {
key={suggestion.suggestion} const childSkills = (suggestion.children ?? [])
icon={suggestion.icon} .map((item) => ({
suggestion={suggestion.suggestion} id: String(item.id).trim(),
onClick={() => handleSuggestionClick(suggestion)} name: item.name?.trim() ?? "",
/> detail: item.detail?.trim() ?? "",
}))
.filter(
(
item,
): item is {
id: string;
name: string;
detail: string;
} => Boolean(item.id) && Boolean(item.name),
);
if (childSkills.length === 0) {
return (
<Suggestion
key={suggestion.suggestion}
icon={suggestion.icon}
suggestion={suggestion.suggestion}
onClick={() => handleSuggestionClick(suggestion)}
/>
);
}
const dropdownKey = suggestion.suggestion;
return (
<DropdownMenu
key={dropdownKey}
modal={false}
open={openDropdownKey === dropdownKey}
onOpenChange={(open) => setOpenDropdownKey(open ? dropdownKey : null)}
>
<DropdownMenuTrigger asChild>
<Suggestion
icon={suggestion.icon}
suggestion={suggestion.suggestion}
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setOpenDropdownKey(dropdownKey)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[240px] rounded-[20px] p-[20px]"
onMouseEnter={() => setOpenDropdownKey(dropdownKey)}
onMouseLeave={() => setOpenDropdownKey(null)}
>
<DropdownMenuLabel className="p-0 text-[14px] text-[#333333]">
{suggestion.suggestion}
</DropdownMenuLabel >
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" />
<DropdownMenuGroup className=" pt-[20px] px-0">
{childSkills.map((item) => (
<Tooltip
key={`${dropdownKey}-${item.id}`}
content={item.detail || item.name}
side="right"
>
<DropdownMenuItem
className="cursor-pointer rounded-[10px] px-3 py-2"
onSelect={(event) => {
event.preventDefault();
handleSuggestionChildClick({
childSkill: item,
suggestionTitle: suggestion.suggestion,
});
}}
>
{item.name}
</DropdownMenuItem>
</Tooltip>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
})()
))} ))}
</Suggestions> </Suggestions>
); );
@ -1148,11 +1246,11 @@ function IframeSkillDialogButton({
key={`${skill.skill_id}-${skill.title}-${index}`} key={`${skill.skill_id}-${skill.title}-${index}`}
className="shrink-0" className="shrink-0"
> >
{skill.title} <span className="text-[12px]/[12px]">{skill.title}</span>
{/* TODO: 因为后端接口不支持取消选择skill所以暂时禁用取消选择按钮 */} {/* TODO: 因为后端接口不支持取消选择skill所以暂时禁用取消选择按钮 */}
<button <button
onClick={() => clearSkill(skill.skill_id)} onClick={() => clearSkill(skill.skill_id)}
className="hover:bg-muted-foreground/20 ml-1 rounded-full" className="hover:bg-muted-foreground/20 ml-1 inline-flex size-4 items-center justify-center rounded-full align-middle"
type="button" type="button"
> >
<XIcon className="size-3" /> <XIcon className="size-3" />

View File

@ -30,6 +30,7 @@ import {
extractContentFromMessage, extractContentFromMessage,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
parseUploadedFiles, parseUploadedFiles,
stripPriorityHintSuffix,
stripUploadedFilesTag, stripUploadedFilesTag,
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
@ -166,7 +167,9 @@ function MessageContent_({
const contentToDisplay = useMemo(() => { const contentToDisplay = useMemo(() => {
if (isHuman) { if (isHuman) {
return rawContent ? stripUploadedFilesTag(rawContent) : ""; return rawContent
? stripPriorityHintSuffix(stripUploadedFilesTag(rawContent))
: "";
} }
return rawContent ?? ""; return rawContent ?? "";
}, [rawContent, isHuman]); }, [rawContent, isHuman]);
@ -421,22 +424,22 @@ function RichFileCard({
/> />
</a> </a>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="min-w-[120px] p-1"> <ContextMenuContent className="min-w-[120px]">
<ContextMenuItem <ContextMenuItem
disabled={!canReference} disabled={!canReference}
onSelect={() => { onClick={() => {
if (!file.path) return; if (!file.path) return;
dispatchMentionReference({ dispatchMentionReference({
threadId, threadId,
filename: file.filename, filename: file.filename,
path: file.path, path: file.path,
ref_source: refSource, ref_source: refSource,
}); });
}} }}
> >
{t.common.reference} {t.common.reference}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
} }

View File

@ -1,5 +1,7 @@
"use client"; "use client";
import { Children, type ComponentProps, isValidElement } from "react";
import { import {
Tooltip as TooltipPrimitive, Tooltip as TooltipPrimitive,
TooltipContent, TooltipContent,
@ -9,15 +11,23 @@ import {
export function Tooltip({ export function Tooltip({
children, children,
content, content,
side,
...props ...props
}: { }: {
children: React.ReactNode; children: React.ReactNode;
side?: ComponentProps<typeof TooltipContent>["side"];
content?: React.ReactNode; content?: React.ReactNode;
}) { }) {
const hasSingleElementChild =
Children.count(children) === 1 && isValidElement(children);
const triggerChild = hasSingleElementChild ? children : <span>{children}</span>;
return ( return (
<TooltipPrimitive delayDuration={500} {...props}> <TooltipPrimitive delayDuration={500} {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{triggerChild}</TooltipTrigger>
<TooltipContent>{content}</TooltipContent> <TooltipContent side={side}>
<span className="whitespace-pre-line">{content}</span>
</TooltipContent>
</TooltipPrimitive> </TooltipPrimitive>
); );
} }

View File

@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react";
export interface SelectedSkillPayloadItem { export interface SelectedSkillPayloadItem {
id: string | number; id: string | number;
name: string; name: string;
detail?: string;
} }
export interface Translations { export interface Translations {

View File

@ -126,31 +126,66 @@ export const zhCN: Translations = {
prompt: prompt:
"为[主题/产品]撰写吸引人的自媒体文案,包括标题、正文和话题标签。", "为[主题/产品]撰写吸引人的自媒体文案,包括标题、正文和话题标签。",
icon: PenLineIcon, icon: PenLineIcon,
children: [{ id: "6057", name: "生辰解语" }], children: [
{
id: "6057",
name: "生辰解语",
detail:
"四柱八字命理分析。\n当用户询问八字、四柱、命理、算命、Bazi、运势预测、命盘分析\n或想了解其八字命盘、运势、大运、流年时请使用此功能。",
},
],
}, },
{ {
suggestion: "小红书种草", suggestion: "小红书种草",
prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。", prompt: "编写[项目/功能]的需求文档,包含功能描述、用户故事和验收标准。",
icon: CompassIcon, icon: CompassIcon,
children: [{ id: "6099", name: "小红书笔记智造官" }], children: [
{
id: "6099",
name: "小红书笔记智造官",
detail:
"根据用户需求及提供资料,撰写小红书笔记内容(标题与正文)。\n生成图片卡片封面及正文卡片并支持发布小红书笔记。",
},
],
}, },
{ {
suggestion: "精美报告", suggestion: "精美报告",
prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。", prompt: "编写[产品/功能]的使用指南,包含操作步骤、注意事项和常见问题。",
icon: GraduationCapIcon, icon: GraduationCapIcon,
children: [{ id: "6100", name: "MD 转 PDF 助手" }], children: [
{
id: "6100",
name: "MD 转 PDF 助手",
detail:
"将 Markdown.md文件转换为专业排版的 PDF 文档。\n支持将 markdown 转换为 PDF。\n支持将 .md 文件转为 .pdf。\n支持从 markdown 生成 PDF 报告。\n适用于中文技术报告、学术论文、文档编写及各类专业排版需求。",
},
],
}, },
{ {
suggestion: "excel数据处理", suggestion: "excel数据处理",
prompt: "对[Excel文件/数据]进行分析,生成数据洞察和可视化建议。", prompt: "对[Excel文件/数据]进行分析,生成数据洞察和可视化建议。",
icon: MicroscopeIcon, icon: MicroscopeIcon,
children: [{ id: "17", name: "Excel处理" }], children: [
{
id: "17",
name: "Excel处理",
detail:
"全面的电子表格创建、编辑与分析功能。\n支持公式运算、格式设置、数据分析和可视化呈现。\n当 Claude 需要处理各类电子表格文件(如 .xlsx、.xlsm、.csv、.tsv 等格式)时,可执行以下操作:\n(1) 创建包含公式与格式的新表格;\n(2) 读取或分析表格数据;\n(3) 在保留公式的前提下修改现有表格;\n(4) 在表格内进行数据分析与可视化处理;\n(5) 重新计算公式。",
},
],
}, },
{ {
suggestion: "营销策划", suggestion: "营销策划",
prompt: "针对[行业/产品]进行市场调研,分析市场规模、竞品和趋势。", prompt: "针对[行业/产品]进行市场调研,分析市场规模、竞品和趋势。",
icon: ShapesIcon, icon: ShapesIcon,
children: [{ id: "217", name: "产品营销背景" }], children: [
{
id: "217",
name: "产品营销背景",
detail:
"当用户需要创建或更新产品营销背景文档时使用。\n可用于沉淀目标用户、市场环境与竞争格局等关键信息。",
},
],
}, },
], ],
suggestionsCreate: [ suggestionsCreate: [

View File

@ -68,6 +68,7 @@ export interface SelectedSkillMessage {
export interface SelectedSkillPayloadItem { export interface SelectedSkillPayloadItem {
id: string | number; id: string | number;
name: string; name: string;
detail?: string;
} }
type UnknownRecord = Record<string, unknown>; type UnknownRecord = Record<string, unknown>;
@ -107,8 +108,16 @@ export function isSelectedSkillsMessage(
if (!skill) return false; if (!skill) return false;
const id = skill.id; const id = skill.id;
const name = skill.name; const name = skill.name;
const detail = skill.detail;
const isValidId = typeof id === "string" || typeof id === "number"; const isValidId = typeof id === "string" || typeof id === "number";
return isValidId && typeof name === "string" && name.trim().length > 0; const isValidDetail =
detail === undefined || typeof detail === "string";
return (
isValidId &&
typeof name === "string" &&
name.trim().length > 0 &&
isValidDetail
);
}); });
} }

View File

@ -351,6 +351,19 @@ export function stripUploadedFilesTag(content: string): string {
.trim(); .trim();
} }
/**
* Strip the appended priority-hint suffix from a message content.
* Suffix format:
* - XClaw优先使用...
* - XClaw优先使用Skill...
* - XClaw优先使用...Skill...
*/
export function stripPriorityHintSuffix(content: string): string {
return content
.replace(/\n?XClaw使[^]+(?:[^]+)?\s*$/u, "")
.trim();
}
export function parseUploadedFiles(content: string): FileInMessage[] { export function parseUploadedFiles(content: string): FileInMessage[] {
// Match <uploaded_files>...</uploaded_files> tag // Match <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/; const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;

View File

@ -4,6 +4,9 @@ import test from "node:test";
const { buildFilesForSubmit } = await import( const { buildFilesForSubmit } = await import(
new URL("./submit-files.ts", import.meta.url).href new URL("./submit-files.ts", import.meta.url).href
); );
const { buildPriorityHintText, composeSubmitText } = await import(
new URL("./priority-hint.ts", import.meta.url).href
);
void test("buildFilesForSubmit keeps uploads and appends valid references", () => { void test("buildFilesForSubmit keeps uploads and appends valid references", () => {
const result = buildFilesForSubmit( const result = buildFilesForSubmit(
@ -67,3 +70,56 @@ void test("buildFilesForSubmit keeps artifact mention path without re-upload", (
assert.equal(result.files[0]?.path, "/mnt/user-data/artifacts/image.png"); assert.equal(result.files[0]?.path, "/mnt/user-data/artifacts/image.png");
assert.equal(result.files[0]?.ref_source, "artifact"); assert.equal(result.files[0]?.ref_source, "artifact");
}); });
void test("buildPriorityHintText keeps attachments first and skills second", () => {
const result = buildPriorityHintText({
attachmentNames: ["spec.md"],
skillIds: ["skill.docs.generate"],
});
assert.equal(result, "XClaw优先使用【spec.md】和【skill.docs.generate】");
});
void test("buildPriorityHintText outputs single category when the other is empty", () => {
assert.equal(
buildPriorityHintText({
attachmentNames: ["spec.md"],
skillIds: [],
}),
"XClaw优先使用【spec.md】",
);
assert.equal(
buildPriorityHintText({
attachmentNames: [],
skillIds: ["skill.docs.generate"],
}),
"XClaw优先使用【skill.docs.generate】",
);
});
void test("buildPriorityHintText deduplicates case-insensitively", () => {
const result = buildPriorityHintText({
attachmentNames: ["Spec.md", "spec.md", " SPEC.md "],
skillIds: ["skill.excel", "SKILL.EXCEL"],
});
assert.equal(result, "XClaw优先使用【Spec.md】和【skill.excel】");
});
void test("composeSubmitText appends hint only when needed", () => {
assert.equal(
composeSubmitText({
baseText: "请总结",
attachmentNames: ["spec.md"],
skillIds: [],
}),
"请总结\nXClaw优先使用【spec.md】",
);
assert.equal(
composeSubmitText({
baseText: "请总结",
attachmentNames: [],
skillIds: [],
}),
"请总结",
);
});

View File

@ -5,9 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api"; import { getAPIClient } from "../api";
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
@ -20,6 +18,7 @@ import { listUploadedFiles, uploadFiles } from "../uploads";
import type { UploadTarget } from "../uploads/api"; import type { UploadTarget } from "../uploads/api";
import { buildFilesForSubmit } from "./submit-files"; import { buildFilesForSubmit } from "./submit-files";
import { buildPriorityHintText, composeSubmitText } from "./priority-hint";
import type { import type {
AgentThread, AgentThread,
AgentThreadContext, AgentThreadContext,
@ -148,6 +147,8 @@ function normalizeThreadId(
return normalized; return normalized;
} }
export { buildPriorityHintText, composeSubmitText };
export function useThreadStreamLegacy({ export function useThreadStreamLegacy({
threadId, threadId,
isNewThread, isNewThread,
@ -256,27 +257,30 @@ export function useThreadStream({
} }
}, []); }, []);
const showStreamErrorToast = useCallback((error: unknown) => { const showStreamErrorToast = useCallback(
const message = getStreamErrorMessage(error); (error: unknown) => {
if (isStreamCancellation(error, message)) { const message = getStreamErrorMessage(error);
// Cancellation is expected when user presses "Stop" or stream disconnects. if (isStreamCancellation(error, message)) {
console.info("[useThreadStream] stream cancelled:", message); // Cancellation is expected when user presses "Stop" or stream disconnects.
return; console.info("[useThreadStream] stream cancelled:", message);
} return;
const now = Date.now(); }
const lastToast = lastErrorToastRef.current; const now = Date.now();
if ( const lastToast = lastErrorToastRef.current;
lastToast && if (
lastToast.message === message && lastToast &&
now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS lastToast.message === message &&
) { now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS
return; ) {
} return;
lastErrorToastRef.current = { message, timestamp: now }; }
console.error("[useThreadStream] conversation stream error:", error); lastErrorToastRef.current = { message, timestamp: now };
console.error("[useThreadStream] parsed error message:", message); console.error("[useThreadStream] conversation stream error:", error);
toast.error(t.threads.streamError); console.error("[useThreadStream] parsed error message:", message);
}, [t.threads.streamError]); toast.error(t.threads.streamError);
},
[t.threads.streamError],
);
const handleStreamStart = useCallback( const handleStreamStart = useCallback(
(_threadId: string) => { (_threadId: string) => {
@ -401,6 +405,12 @@ export function useThreadStream({
sendInFlightRef.current = true; sendInFlightRef.current = true;
const text = message.text.trim(); const text = message.text.trim();
const referenceNames = (message.references ?? []).map(
(reference) => reference.filename,
);
const selectedSkillIds = (message.selectedSkills ?? []).map(
(skill) => skill.skill_id,
);
const resolvedThreadId = const resolvedThreadId =
normalizeThreadId(threadId) ?? normalizeThreadId(threadId) ??
normalizeThreadId(threadIdRef.current) ?? normalizeThreadId(threadIdRef.current) ??
@ -501,9 +511,7 @@ export function useThreadStream({
const failedConversions = conversionResults.length - files.length; const failedConversions = conversionResults.length - files.length;
if (failedConversions > 0) { if (failedConversions > 0) {
throw new Error( throw new Error(t.threads.uploadPrepareFailed(failedConversions));
t.threads.uploadPrepareFailed(failedConversions),
);
} }
if (!resolvedThreadId) { if (!resolvedThreadId) {
@ -563,6 +571,16 @@ export function useThreadStream({
toast.error(t.threads.staleReferencesRemoved); toast.error(t.threads.staleReferencesRemoved);
} }
const uploadedNames =
uploadedFileInfo.length > 0
? uploadedFileInfo.map((file) => file.filename)
: (message.files ?? []).map((file) => file.filename ?? "");
const submitText = composeSubmitText({
baseText: text,
attachmentNames: [...uploadedNames, ...referenceNames],
skillIds: selectedSkillIds,
});
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
@ -571,7 +589,7 @@ export function useThreadStream({
content: [ content: [
{ {
type: "text", type: "text",
text, text: submitText,
}, },
], ],
additional_kwargs: additional_kwargs:
@ -671,6 +689,12 @@ export function useSubmitThread({
return; return;
} }
const text = message.text.trim(); const text = message.text.trim();
const referenceNames = (message.references ?? []).map(
(reference) => reference.filename,
);
const selectedSkillIds = (message.selectedSkills ?? []).map(
(skill) => skill.skill_id,
);
const hasFiles = !!(message.files && message.files.length > 0); const hasFiles = !!(message.files && message.files.length > 0);
const hasReferences = !!( const hasReferences = !!(
@ -736,6 +760,15 @@ export function useSubmitThread({
toast.error(t.threads.staleReferencesRemoved); toast.error(t.threads.staleReferencesRemoved);
} }
const uploadedNames = (message.files ?? []).map(
(file) => file.filename ?? "",
);
const submitText = composeSubmitText({
baseText: text,
attachmentNames: [...uploadedNames, ...referenceNames],
skillIds: selectedSkillIds,
});
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
@ -744,7 +777,7 @@ export function useSubmitThread({
content: [ content: [
{ {
type: "text", type: "text",
text, text: submitText,
}, },
], ],
additional_kwargs: additional_kwargs:

View File

@ -0,0 +1,55 @@
function uniqueNormalizedValues(values: Array<string | undefined>): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const normalized = value?.trim();
if (!normalized) continue;
const dedupeKey = normalized.toLocaleLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(normalized);
}
return result;
}
export function buildPriorityHintText({
attachmentNames,
skillIds,
}: {
attachmentNames: string[];
skillIds: string[];
}): string {
const attachments = uniqueNormalizedValues(attachmentNames);
const skills = uniqueNormalizedValues(skillIds);
if (attachments.length === 0 && skills.length === 0) {
return "";
}
const attachmentPart =
attachments.length > 0 ? `${attachments.join("、")}` : "";
const skillPart = skills.length > 0 ? `${skills.join("、")}` : "";
if (attachmentPart && skillPart) {
return `XClaw优先使用${attachmentPart}${skillPart}`;
}
return `XClaw优先使用${attachmentPart || skillPart}`;
}
export function composeSubmitText({
baseText,
attachmentNames,
skillIds,
}: {
baseText: string;
attachmentNames: string[];
skillIds: string[];
}): string {
const trimmedBase = baseText.trim();
if (!trimmedBase) return trimmedBase;
const priorityHint = buildPriorityHintText({
attachmentNames,
skillIds,
});
if (!priorityHint) return trimmedBase;
return `${trimmedBase}\n${priorityHint}`;
}

View File

@ -30,6 +30,26 @@ function getThreadStorageKey(threadId?: string | null): string | null {
return `${STORAGE_KEYS.byThreadPrefix}${normalized}`; return `${STORAGE_KEYS.byThreadPrefix}${normalized}`;
} }
function persistSkillsToSessionStorage(
skills: SkillData[],
threadId?: string | null,
) {
const threadKey = getThreadStorageKey(threadId);
if (skills.length === 0) {
window.sessionStorage.removeItem(STORAGE_KEYS.latest);
if (threadKey) {
window.sessionStorage.removeItem(threadKey);
}
return;
}
const payload = JSON.stringify(skills);
window.sessionStorage.setItem(STORAGE_KEYS.latest, payload);
if (threadKey) {
window.sessionStorage.setItem(threadKey, payload);
}
}
function parseStoredSkills(raw: string | null): SkillData[] { function parseStoredSkills(raw: string | null): SkillData[] {
if (!raw) return []; if (!raw) return [];
try { try {
@ -103,39 +123,39 @@ export function useIframeSkill(
return next; return next;
}); });
// 2) 回滚 localStoragelatest + thread // 2) 回滚 sessionStoragelatest + thread
const latestSkills = parseStoredSkills( const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest), window.sessionStorage.getItem(STORAGE_KEYS.latest),
); );
const nextLatestSkills = removeSkillsByIdsFromList( const nextLatestSkills = removeSkillsByIdsFromList(
latestSkills, latestSkills,
skillIds, skillIds,
); );
if (nextLatestSkills.length > 0) { if (nextLatestSkills.length > 0) {
window.localStorage.setItem( window.sessionStorage.setItem(
STORAGE_KEYS.latest, STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills), JSON.stringify(nextLatestSkills),
); );
} else { } else {
window.localStorage.removeItem(STORAGE_KEYS.latest); window.sessionStorage.removeItem(STORAGE_KEYS.latest);
} }
const threadKey = getThreadStorageKey(threadId); const threadKey = getThreadStorageKey(threadId);
if (threadKey) { if (threadKey) {
const threadSkills = parseStoredSkills( const threadSkills = parseStoredSkills(
window.localStorage.getItem(threadKey), window.sessionStorage.getItem(threadKey),
); );
const nextThreadSkills = removeSkillsByIdsFromList( const nextThreadSkills = removeSkillsByIdsFromList(
threadSkills, threadSkills,
skillIds, skillIds,
); );
if (nextThreadSkills.length > 0) { if (nextThreadSkills.length > 0) {
window.localStorage.setItem( window.sessionStorage.setItem(
threadKey, threadKey,
JSON.stringify(nextThreadSkills), JSON.stringify(nextThreadSkills),
); );
} else { } else {
window.localStorage.removeItem(threadKey); window.sessionStorage.removeItem(threadKey);
} }
} }
}, },
@ -196,39 +216,27 @@ export function useIframeSkill(
return () => window.removeEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage);
}, []); }, []);
// 3. 首次进入时恢复 localStorage 中上次选择的 skill线程优先其次全局 // 3. 首次进入时恢复 sessionStorage 中上次选择的 skill线程优先其次全局
useEffect(() => { // 已按需求注释:关闭页签后重新打开时,不再从 sessionStorage 自动恢复。
const threadKey = getThreadStorageKey(threadId); // useEffect(() => {
const threadSkills = threadKey // const threadKey = getThreadStorageKey(threadId);
? parseStoredSkills(window.localStorage.getItem(threadKey)) // const threadSkills = threadKey
: []; // ? parseStoredSkills(window.sessionStorage.getItem(threadKey))
const latestSkills = parseStoredSkills( // : [];
window.localStorage.getItem(STORAGE_KEYS.latest), // const latestSkills = parseStoredSkills(
); // window.sessionStorage.getItem(STORAGE_KEYS.latest),
const restoredSkills = // );
threadSkills.length > 0 ? threadSkills : latestSkills; // const restoredSkills =
if (restoredSkills.length === 0) return; // threadSkills.length > 0 ? threadSkills : latestSkills;
setSelectedSkills(restoredSkills); // if (restoredSkills.length === 0) return;
setSelectedSkill(restoredSkills[0] ?? null); // setSelectedSkills(restoredSkills);
}, [threadId]); // setSelectedSkill(restoredSkills[0] ?? null);
// }, [threadId]);
// 4. 选择变化时同步到 localStorage // 4. 选择变化时同步到 sessionStorage
useEffect(() => { useEffect(() => {
const threadKey = getThreadStorageKey(threadId); // 空数组也要同步到存储,避免 UI 状态与缓存不一致
if (selectedSkills.length === 0) { persistSkillsToSessionStorage(selectedSkills, threadId);
// 空数组也要同步到存储,避免 UI 状态与缓存不一致
window.localStorage.removeItem(STORAGE_KEYS.latest);
if (threadKey) {
window.localStorage.removeItem(threadKey);
}
return;
}
const payload = JSON.stringify(selectedSkills);
window.localStorage.setItem(STORAGE_KEYS.latest, payload);
if (threadKey) {
window.localStorage.setItem(threadKey, payload);
}
}, [selectedSkills, threadId]); }, [selectedSkills, threadId]);
// 发送选择预定义 skill // 发送选择预定义 skill
@ -285,6 +293,15 @@ export function useIframeSkill(
}); });
try { try {
// 发起 bootstrap 时立即同步缓存;失败会在 removeFailedSkills 中回滚。
const normalizedSkills = selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
persistSkillsToSessionStorage(normalizedSkills, threadId);
const result = await bootstrapRemoteSkill({ const result = await bootstrapRemoteSkill({
thread_id: threadId, thread_id: threadId,
content_ids, content_ids,
@ -307,12 +324,12 @@ export function useIframeSkill(
} }
sendSelectSkill(selectedSkills); sendSelectSkill(selectedSkills);
const normalizedSkills = selectedSkills.map((item) => ({ const latestSkills = selectedSkills.map((item) => ({
skill_id: String(item.id), skill_id: String(item.id),
title: item.name, title: item.name,
})); }));
setSelectedSkill(normalizedSkills[0] ?? null); setSelectedSkill(latestSkills[0] ?? null);
setSelectedSkills(normalizedSkills); setSelectedSkills(latestSkills);
toast.success(t.skills.loadSuccessWithTitle(title), { toast.success(t.skills.loadSuccessWithTitle(title), {
description: result.message || t.skills.createdFiles(result.created_files), description: result.message || t.skills.createdFiles(result.created_files),
@ -359,30 +376,30 @@ export function useIframeSkill(
// 同步 latest 缓存:仅删除对应 skill或全部清空 // 同步 latest 缓存:仅删除对应 skill或全部清空
const latestSkills = parseStoredSkills( const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest), window.sessionStorage.getItem(STORAGE_KEYS.latest),
); );
const nextLatestSkills = removeAll const nextLatestSkills = removeAll
? [] ? []
: latestSkills.filter((skill) => skill.skill_id !== String(skillId)); : latestSkills.filter((skill) => skill.skill_id !== String(skillId));
if (nextLatestSkills.length > 0) { if (nextLatestSkills.length > 0) {
window.localStorage.setItem( window.sessionStorage.setItem(
STORAGE_KEYS.latest, STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills), JSON.stringify(nextLatestSkills),
); );
} else { } else {
window.localStorage.removeItem(STORAGE_KEYS.latest); window.sessionStorage.removeItem(STORAGE_KEYS.latest);
} }
// 同步线程缓存:保存剩余数组,空则删除 key // 同步线程缓存:保存剩余数组,空则删除 key
const threadKey = getThreadStorageKey(threadId); const threadKey = getThreadStorageKey(threadId);
if (threadKey) { if (threadKey) {
if (nextSelectedSkills.length > 0) { if (nextSelectedSkills.length > 0) {
window.localStorage.setItem( window.sessionStorage.setItem(
threadKey, threadKey,
JSON.stringify(nextSelectedSkills), JSON.stringify(nextSelectedSkills),
); );
} else { } else {
window.localStorage.removeItem(threadKey); window.sessionStorage.removeItem(threadKey);
} }
} }

View File

@ -462,12 +462,12 @@ pre{
/* 二三级标题 - 16px */ /* 二三级标题 - 16px */
[data-streamdown="heading-2"], [data-streamdown="heading-2"],
[data-streamdown="heading-3"] { [data-streamdown="heading-3"],[data-streamdown="heading-4"] {
font-size: calc(16px * var(--zoom-scale)); font-size: calc(16px * var(--zoom-scale));
} }
/* 代码块 - 14px */ /* 代码块 - 14px */
[data-streamdown="code-block"] pre { [data-streamdown="code-block"] pre,code {
font-size: calc(14px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
} }
@ -481,13 +481,19 @@ pre{
[data-streamdown="table-cell"] { [data-streamdown="table-cell"] {
background-color: transparent; background-color: transparent;
font-size: calc(14px * var(--zoom-scale));
height:calc(42px * var(--zoom-scale)) ;
} }
[data-streamdown="table-header"] { [data-streamdown="table-header"] {
background: #9c9b9b26; background: #9c9b9b26;
height: 50px; height: calc(50px * var(--zoom-scale));
} }
[data-streamdown="table-header"] th { [data-streamdown="table-header"] th {
text-align: center; text-align: center;
font-size: calc(14px * var(--zoom-scale));
}
[data-slot="hover-card-trigger"] [data-slot="badge"]{
font-size: calc(14px * var(--zoom-scale));
} }
@ -500,7 +506,7 @@ pre{
border-top-right-radius: 5px; border-top-right-radius: 5px;
} }
[data-streamdown="table-body"] tr:first-child td{ [data-streamdown="table-body"] tr:first-child td{
padding-top: 20px; padding-top: calc(20px * var(--zoom-scale));
} }
/* 行分隔线 */ /* 行分隔线 */
[data-streamdown="table-body"] tr{ [data-streamdown="table-body"] tr{
@ -515,14 +521,13 @@ pre{
} }
[data-streamdown="table-body"] tr:last-child { [data-streamdown="table-body"] tr:last-child {
height: 50px; padding-top: calc(50px * var(--zoom-scale));
} }
[data-streamdown="table-row"] >[data-streamdown="table-cell"]{ [data-streamdown="table-row"] >[data-streamdown="table-cell"]{
line-height: 14px; line-height: 14px;
vertical-align: top; vertical-align: top;
text-align: center; text-align: center;
} }

View File

@ -141,10 +141,9 @@ test.describe("聊天工作台 / 输入区与发送", () => {
).toHaveCount(1); ).toHaveCount(1);
}); });
test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async ( test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async ({
{ page }, page,
testInfo, }, testInfo) => {
) => {
skipIfMissingThread( skipIfMissingThread(
testInfo, testInfo,
THREAD_WITH_REFERENCE_FIXTURE, THREAD_WITH_REFERENCE_FIXTURE,
@ -174,10 +173,9 @@ test.describe("聊天工作台 / 输入区与发送", () => {
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
}); });
test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async ( test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async ({
{ page }, page,
testInfo, }, testInfo) => {
) => {
skipIfMissingThread( skipIfMissingThread(
testInfo, testInfo,
THREAD_WITH_STALE_REFERENCE, THREAD_WITH_STALE_REFERENCE,
@ -230,7 +228,9 @@ test.describe("聊天工作台 / 输入区与发送", () => {
request.method() === "POST" && request.method() === "POST" &&
request request
.url() .url()
.includes(`/api/langgraph/threads/${THREAD_WITH_STALE_REFERENCE}/runs/stream`), .includes(
`/api/langgraph/threads/${THREAD_WITH_STALE_REFERENCE}/runs/stream`,
),
); );
await page.locator("button[aria-label='Submit']").click(); await page.locator("button[aria-label='Submit']").click();
await expect.poll(() => staleArtifactRequested).toBe(true); await expect.poll(() => staleArtifactRequested).toBe(true);
@ -242,10 +242,59 @@ test.describe("聊天工作台 / 输入区与发送", () => {
await expect(page.locator("textarea[name='message']")).toHaveValue(""); await expect(page.locator("textarea[name='message']")).toHaveValue("");
}); });
test("DF-INPUT-009 引用上限为 10第 11 个被阻止并提示", async ( test("DF-INPUT-008A 提交态附加优先提示但消息区只显示原文", async ({
{ page }, page,
testInfo, }, testInfo) => {
) => { skipIfMissingThread(
testInfo,
THREAD_WITH_REFERENCE_FIXTURE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_REFERENCE_FIXTURE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_REFERENCE_FIXTURE!));
await expandComposer(page);
const textarea = page.locator("textarea[name='message']");
const userInput = "请根据引用文件给出摘要";
await textarea.fill(`${userInput} `);
await openReferencePicker(page);
const firstReference = page.getByTestId("mention-candidate-item").first();
await expect(firstReference).toBeVisible();
const referenceName =
REFERENCE_UPLOAD_FIXTURES[0]?.filename ?? "fixture-01.md";
await firstReference.click();
await expect(page.getByTestId("reference-chip")).toHaveCount(1);
const submitRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
request
.url()
.includes(
`/api/langgraph/threads/${THREAD_WITH_REFERENCE_FIXTURE}/runs/stream`,
),
);
await page.locator("button[aria-label='Submit']").click();
const request = await submitRequest;
const requestBody = request.postData() ?? "";
expect(requestBody).toContain(userInput);
expect(requestBody).toContain("XClaw优先使用【");
expect(requestBody).toContain(referenceName);
await expect(
page.locator(".is-user").filter({ hasText: "XClaw优先使用【" }),
).toHaveCount(0);
});
test("DF-INPUT-009 引用上限为 10第 11 个被阻止并提示", async ({
page,
}, testInfo) => {
skipIfMissingThread( skipIfMissingThread(
testInfo, testInfo,
THREAD_WITH_REFERENCE_FIXTURE, THREAD_WITH_REFERENCE_FIXTURE,
@ -291,8 +340,6 @@ test.describe("聊天工作台 / 输入区与发送", () => {
}); });
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10); await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10);
await expect( await expect(toastByText(page, "单条消息最多引用 10 个文件")).toBeVisible();
toastByText(page, "单条消息最多引用 10 个文件"),
).toBeVisible();
}); });
}); });