Compare commits
6
Commits
83ad10d95e
...
da29294979
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da29294979 | ||
|
|
8042d2be26 | ||
|
|
df02680f9d | ||
|
|
a4fc3a5251 | ||
|
|
47f0095720 | ||
|
|
2330401428 |
No files matched your search
@@ -4,6 +4,23 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
|
||||
## Integrated Through
|
||||
|
||||
- Coding project setup UX source `8042d2be265e40288e628fcdc755e9f29151306b`
|
||||
from task `20260906-project-setup-ux-90fe6cf2` is integrated onto local `main`
|
||||
by task `20260906-integrate-project-setup-ux-4b7d2e91`. A Coding Conversation is
|
||||
ready only when the project is persisted as initialized and has at least one enabled,
|
||||
unarchived project Agent with a non-empty name, avatar, role, responsibility, and
|
||||
resolved model. Project-list entry, direct route resolution, the module gate, and the
|
||||
Coding chat recovery surface all reuse that predicate. Project Configuration presents
|
||||
the missing first Agent as a required step, sends users to model settings when no model
|
||||
exists, persists the first valid Agent before entering chat, offers creation instead of
|
||||
an unrecoverable edit for disabled-only projects, and uses labelled deterministic exit
|
||||
actions with unsaved-change confirmation. A direct `/chat` visit without a ready Agent
|
||||
renders a configuration recovery action without creating a Conversation/runtime or
|
||||
exposing the Composer. Source verification passed 63 focused tests, the 1,928-test full
|
||||
unit suite with 2 skips plus pressure, typecheck, lint with 0 errors/5 unchanged warnings,
|
||||
all Vite targets, the targeted Electron flow, and independent review. Persistence,
|
||||
Pi/runtime, Provider, Plugin, package, deployment, publication, and remote state are
|
||||
unchanged.
|
||||
- Token Point V2 account-usage source `6348e402a4e8dde712e5cba4620bb883ffe24e0b`
|
||||
from task `20260906-trace-makelore-usage-3d7a5c1e` is integrated onto local
|
||||
`main` by task `20260906-integrate-token-points-v2-7b4e1c92`. The account menu
|
||||
@@ -714,7 +731,7 @@ Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/ap
|
||||
|
||||
AI 学习现在是已启用的运营精选项目目录,并继续受登录和 `module_access.learning` 控制。Renderer 通过 Main-owned Host API 获取分页项目卡片和 README 详情;Markdown 支持 GFM、禁用原始 HTML。服务端发布时只校验图片 URL 为无凭据、默认端口、无 fragment 且当前 DNS 结果全部为公网地址的 HTTPS URL,保留地址而不下载、识别格式、转码或镜像;客户端仅为 README 图片节点启用直连,因此 SVG 和 Electron 支持的其他格式可直接显示,单图失败不阻断详情。封面和历史发布媒体继续走受控路径。详情页的下载按钮打开系统保存对话框;Main 将 ZIP 流式写入临时文件,只允许最多五跳同 Works origin 重定向,不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限,校验 SHA-256 和 ZIP 签名后原子保存,Renderer 只接收 `saved` 或 `cancelled`。课程生成、进度、本地课程库、OpenMAIC player、Agent、ASR、课堂 runtime、Learning IPC 和 player artifact 打包已删除且没有兼容读取路径;历史课程数据保留但不再读取。服务端和客户端源码契约已完成,不代表生产部署或真实账号安装包联调已经完成。
|
||||
|
||||
AI 编程已经硬切到精确 pin 的 Pi `0.84.2`,不存在 OpenCode fallback 或双 runtime。project、Agent 与 Conversation 只在 `.makelore/project.json` 和 `.makelore/conversations.json` 使用本地 schema v2;当前客户端不从 `.niancode` 或 `.opencode` 读取、迁移或删除项目元数据。Electron Main 按需启动一个长驻父 Agent Server,每条 active/warm Conversation 在其中拥有独立 Runtime/Session/channel、credential store、extension context、generation/seq、Snapshot/Patch、model/thinking、队列、interaction 与错误状态,Composer 在 lazy prepare 期间仍可编辑;正式包从 staged `pi-runtime` manifest/root 定位并校验 Pi 包入口,不从脚本目录或应用 `node_modules` 回退。Renderer 只通过 `/api/coding/*` 和 Snapshot-first/`patch-batch` SSE 消费产品中立合同;gap/reconnect 只恢复目标 Conversation,accepted/uncertain mutation 不自动重放。`lifecycle:sleep` 会关闭旧事件流,视图挂载、项目上下文变化、页面重新可见或窗口 focus 会静默刷新已选 Snapshot,使后台 terminal 状态收敛且不重放 mutation。隐藏 Conversation 的红点只在新 pending interaction 或新 completed/failed/aborted terminal transition 出现,不由助手/thinking/工具过程或单个工具失败触发。Session hydration 沿完整 active branch 投影可见历史,Pi compaction 只改变模型上下文并保持 summary 私有;Renderer 首次挂载最近 120 个节点,向上滚动时按 100 个节点追加更早内容并补偿新增高度以保持阅读锚点。折叠的 thinking、助手过程说明与工具输出固定展示第一条可见内容和首个非空行,横向偏移保持为零,展开后仍显示完整内容。未解析 Conversation 第一次选模先 validate 并持久化 resolved metadata,再 prepare;同账号模型切换使用 target `set_model`,跨账号只重建目标逻辑线程。Web Search 只作为所选模型 capability 进入父 worker 并使用相同 model/provider/credential 与普通模型计费,不经过 Marketplace/Hosted client/Plugin Charge 或浏览器 fallback;child 不继承。Conversation 工具可检查并在独立确认后安装 npm、Git、本地 Plugin 目录或 loose `SKILL.md` 为 Main-owned immutable Device Package;不运行生命周期脚本,可执行 extension 具有桌面用户权限,新/idle parent 自动刷新,active parent 结算后刷新,child 为空。每个 Main-selected generation 都保留全部显式安装且当前启用的 Skill 路径;生成的 Makelore bridge 是必需的首个 extension,其余 extension 全部经 `additionalExtensionPaths` 加载,并继续关闭 ambient discovery。top-level 逻辑 turn 并发为 4,warm idle logical-thread LRU 为 8;independent child 进程并发为 4 并使用 FIFO 进程预算 8;coding child 与 parent 共用项目 write lease。同一助手工具批次内,内置 `bash`/`edit`/`write` 与声明需要该 write lease 的动态产品工具按顺序执行,避免 Pi 在批量 prepare 阶段形成租约自锁;纯只读工具批次仍可并行。prompt/compact confirmation timeout 后仍保留 run/Agent-Server-or-child-process/background ownership,迟到 success/failure/exit/abort 单调且 exactly-once 收敛,页面隐藏不会停止 active/uncertain run。线程级替换只使目标 generation 失效;整个 Agent Server 退出会统一使所有旧 channel 失效,但 Main/Renderer 存活且下次恢复只重启一个 Server。Pi `0.84.2` 手动 compact 不发 `agent_settled`,由 correlated compact RPC 结果终结。父 Provider credential 只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;确定性的 Works user-context 缺失会失效 gateway credential、fail fast、不重放并投影固定 Provider-auth 错误,不归类为 Pi crash。精确的 `403 token_point_balance_exhausted` / `词元点数余额不足` 上游响应会在 Main 内归类为不可重试的配额耗尽,Renderer 只收到稳定 `CODING_PROVIDER_QUOTA_EXHAUSTED` 与安全中文提示;重开会话仍保留该提示,不暴露上游 request id 或原始错误正文。真实 Provider 验证仍为用户明确接受的未验证风险;macOS arm64 仅有本地未签名 mounted-image initialize/shutdown 证据,签名/公证/完整 process-enumeration、macOS x64 与 native non-WSL Linux 仍未通过平台发布门禁。
|
||||
AI 编程已经硬切到精确 pin 的 Pi `0.84.2`,不存在 OpenCode fallback 或双 runtime。project、Agent 与 Conversation 只在 `.makelore/project.json` 和 `.makelore/conversations.json` 使用本地 schema v2;当前客户端不从 `.niancode` 或 `.opencode` 读取、迁移或删除项目元数据。Coding Project 只有在 `initialized` 且至少存在一个启用、未归档、名称/头像/角色/职责/解析模型完整的项目 Agent 时才可进入 Conversation;项目入口、路由门禁、配置完成和 Chat 恢复必须复用共享判定,不能单独信任 `initialized`。Electron Main 按需启动一个长驻父 Agent Server,每条 active/warm Conversation 在其中拥有独立 Runtime/Session/channel、credential store、extension context、generation/seq、Snapshot/Patch、model/thinking、队列、interaction 与错误状态,Composer 在 lazy prepare 期间仍可编辑;正式包从 staged `pi-runtime` manifest/root 定位并校验 Pi 包入口,不从脚本目录或应用 `node_modules` 回退。Renderer 只通过 `/api/coding/*` 和 Snapshot-first/`patch-batch` SSE 消费产品中立合同;gap/reconnect 只恢复目标 Conversation,accepted/uncertain mutation 不自动重放。`lifecycle:sleep` 会关闭旧事件流,视图挂载、项目上下文变化、页面重新可见或窗口 focus 会静默刷新已选 Snapshot,使后台 terminal 状态收敛且不重放 mutation。隐藏 Conversation 的红点只在新 pending interaction 或新 completed/failed/aborted terminal transition 出现,不由助手/thinking/工具过程或单个工具失败触发。Session hydration 沿完整 active branch 投影可见历史,Pi compaction 只改变模型上下文并保持 summary 私有;Renderer 首次挂载最近 120 个节点,向上滚动时按 100 个节点追加更早内容并补偿新增高度以保持阅读锚点。折叠的 thinking、助手过程说明与工具输出固定展示第一条可见内容和首个非空行,横向偏移保持为零,展开后仍显示完整内容。未解析 Conversation 第一次选模先 validate 并持久化 resolved metadata,再 prepare;同账号模型切换使用 target `set_model`,跨账号只重建目标逻辑线程。Web Search 只作为所选模型 capability 进入父 worker 并使用相同 model/provider/credential 与普通模型计费,不经过 Marketplace/Hosted client/Plugin Charge 或浏览器 fallback;child 不继承。Conversation 工具可检查并在独立确认后安装 npm、Git、本地 Plugin 目录或 loose `SKILL.md` 为 Main-owned immutable Device Package;不运行生命周期脚本,可执行 extension 具有桌面用户权限,新/idle parent 自动刷新,active parent 结算后刷新,child 为空。每个 Main-selected generation 都保留全部显式安装且当前启用的 Skill 路径;生成的 Makelore bridge 是必需的首个 extension,其余 extension 全部经 `additionalExtensionPaths` 加载,并继续关闭 ambient discovery。top-level 逻辑 turn 并发为 4,warm idle logical-thread LRU 为 8;independent child 进程并发为 4 并使用 FIFO 进程预算 8;coding child 与 parent 共用项目 write lease。同一助手工具批次内,内置 `bash`/`edit`/`write` 与声明需要该 write lease 的动态产品工具按顺序执行,避免 Pi 在批量 prepare 阶段形成租约自锁;纯只读工具批次仍可并行。prompt/compact confirmation timeout 后仍保留 run/Agent-Server-or-child-process/background ownership,迟到 success/failure/exit/abort 单调且 exactly-once 收敛,页面隐藏不会停止 active/uncertain run。线程级替换只使目标 generation 失效;整个 Agent Server 退出会统一使所有旧 channel 失效,但 Main/Renderer 存活且下次恢复只重启一个 Server。Pi `0.84.2` 手动 compact 不发 `agent_settled`,由 correlated compact RPC 结果终结。父 Provider credential 只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;确定性的 Works user-context 缺失会失效 gateway credential、fail fast、不重放并投影固定 Provider-auth 错误,不归类为 Pi crash。精确的 `403 token_point_balance_exhausted` / `词元点数余额不足` 上游响应会在 Main 内归类为不可重试的配额耗尽,Renderer 只收到稳定 `CODING_PROVIDER_QUOTA_EXHAUSTED` 与安全中文提示;重开会话仍保留该提示,不暴露上游 request id 或原始错误正文。真实 Provider 验证仍为用户明确接受的未验证风险;macOS arm64 仅有本地未签名 mounted-image initialize/shutdown 证据,签名/公证/完整 process-enumeration、macOS x64 与 native non-WSL Linux 仍未通过平台发布门禁。
|
||||
|
||||
Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失败语义。正式稳定源缺少对应平台 manifest 时,设置页只显示一条简洁中文提示并允许重试,不把缺包误报为已是最新版,也不向普通界面暴露堆栈、URL、路径或错误码;签名产物发布和真实升级安装仍属于外部 Release Gate。
|
||||
|
||||
@@ -855,4 +872,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-09-05
|
||||
2026-09-06
|
||||
@@ -0,0 +1,65 @@
|
||||
# Task: Integrate project setup UX
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260906-integrate-project-setup-ux-4b7d2e91
|
||||
- Mode: Integration
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\OthersProjects\makelore
|
||||
- Base commit: 83ad10d95e607c10973fed543cc5fa09afdcb9f7
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Integrate source task `20260906-project-setup-ux-90fe6cf2` at `8042d2be265e40288e628fcdc755e9f29151306b` onto `main` at `83ad10d95e607c10973fed543cc5fa09afdcb9f7`.
|
||||
- Preserve the Token Point V2 sidebar behavior already on `main` while adopting the shared Coding Project conversation-readiness gate and project-setup onboarding flow.
|
||||
- Promote the source task's accepted readiness invariant into `.project-docs/30-worklog/current-state.md`.
|
||||
- Verify the exact merged tree and leave the three pre-existing adopted task records unchanged and outside the integration commit.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Use a normal non-history-rewriting merge; do not squash, rebase, force-push, package, deploy, or push remotely.
|
||||
- Treat the source task record as read-only source evidence until it is intentionally adopted as part of the merge commit.
|
||||
- Preserve the accepted `.makelore/project.json` authority, Pi runtime structure, Main-owned boundaries, and single light visual system.
|
||||
- Do not modify or commit the adopted records `20260901-package-122-c5e8.md`, `20260901-package-123-d7f3.md`, or `20260902-client-hang-diagnosis-a47c9e2b.md`.
|
||||
- Re-run focused onboarding/readiness tests, typecheck, lint, Renderer build, the relevant Electron E2E, and the full unit suite because the source and destination both modify `Sidebar.tsx`.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Merged `codex/20260906-project-setup-ux-90fe6cf2-project-setup-ux` into the
|
||||
current `main` with `--no-commit --no-ff`; Git reconciled the shared
|
||||
`src/components/layout/Sidebar.tsx` change without conflicts.
|
||||
- Preserved the Token Point V2 version of Sidebar and added only the shared Coding
|
||||
Project conversation-readiness import and navigation predicate from the source.
|
||||
- Integrated the shared readiness contract, required first-Agent setup card,
|
||||
deterministic configuration exits, and Coding chat recovery state together with
|
||||
their focused unit and Electron E2E coverage.
|
||||
- Registered the unchanged source task record as an adopted integration input and
|
||||
promoted its accepted readiness invariant into canonical `current-state.md`.
|
||||
- Kept the three pre-existing adopted task records unstaged and unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused Vitest passed 5 files / 63 tests for readiness, App and MainLayout gates,
|
||||
Project Configuration onboarding, and Coding chat recovery.
|
||||
- Full `pnpm test` passed 231 files / 1,936 tests with 2 skipped; the separately
|
||||
serialized Coding chat pressure test passed 1/1.
|
||||
- `pnpm run typecheck` passed.
|
||||
- `pnpm run lint:check` passed with 0 errors and the 5 unchanged warnings in
|
||||
`src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
|
||||
- `pnpm run build:vite` passed for Renderer, Electron Main, preload, and utility
|
||||
bundles; only the repository's existing Browserslist, dynamic-import, and chunk-size
|
||||
advisories were reported.
|
||||
- Electron E2E `tests/e2e/project-configuration-skills.spec.ts` passed 1/1.
|
||||
- `check_project_docs.py`, task-aware `check_doc_drift.py`, the unchanged source-task
|
||||
record comparison, and `git diff --check` all passed before the merge commit.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- None recorded.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. This Integration Gate applies the accepted source promotion directly to
|
||||
`.project-docs/30-worklog/current-state.md`.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Task: Review new project setup UX
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260906-project-setup-ux-90fe6cf2
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260906-project-setup-ux-90fe6cf2-project-setup-ux
|
||||
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260906-project-setup-ux-90fe6cf2
|
||||
- Base commit: 5c61110f465cc4172f712ad435c06041c0aed1ec
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Review the new-project transition from Project Configuration to the first Coding
|
||||
Conversation, with particular attention to the zero-Agent state and the discoverability
|
||||
of the return/continue action.
|
||||
- Verify the persisted readiness rule, route behavior, empty states, model prerequisite,
|
||||
and current product terminology before recommending an interaction change.
|
||||
- Implement the accepted interaction direction with one shared Conversation-readiness
|
||||
rule, a strong first-Agent setup path, deterministic navigation, and a recoverable
|
||||
zero-Agent Conversation state.
|
||||
- Add focused unit and Electron E2E coverage while keeping persistence, Pi runtime,
|
||||
Provider, Plugin, and Main/Renderer contracts unchanged.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve the explicit product decision that new projects have no default project Agent
|
||||
and that the user chooses the Agent identity, model, responsibility, and Skills.
|
||||
- Keep project-owned parent Agents distinct from short-lived runtime child Agents; do not
|
||||
describe the Project Configuration entity as a child Agent.
|
||||
- Preserve Pi `0.84.2`, `.makelore` ownership, the single light visual system, and the
|
||||
current Main/Renderer boundary.
|
||||
- Prefer one explicit first-run completion path over a forced tutorial, compatibility
|
||||
layer, new state machine, or automatically fabricated default Agent.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Confirmed that the screenshot's “项目智能体” is the persisted project-owned parent
|
||||
Agent, not an ephemeral runtime child Agent, and retained that terminology throughout.
|
||||
- Added one shared Conversation-readiness rule: `initialized` must be true and the project
|
||||
must contain at least one enabled, unarchived Agent with a non-empty name, avatar, role,
|
||||
responsibility, and resolved model. Project list entry, direct `/chat` resolution,
|
||||
the programming-module gate, and Coding chat now use the same rule instead of trusting
|
||||
`initialized` alone.
|
||||
- Reworked the zero-ready-Agent configuration state into a prominent required-step card.
|
||||
With no model it offers “先配置模型” and a direct model-settings action; otherwise it
|
||||
creates the first Agent or resumes an enabled incomplete Agent. Disabled-only state
|
||||
intentionally offers a new Agent because the edit dialog has no enable control.
|
||||
- The first Agent is persisted immediately and then navigates to `/chat`. Later Agent
|
||||
additions and edits retain the existing page-draft plus explicit-save behavior.
|
||||
- Replaced history-relative back navigation with labelled, deterministic “返回对话” or
|
||||
“稍后设置” destinations. Unsaved Agent changes are confirmed before any page exit,
|
||||
including the model-settings prerequisite path.
|
||||
- Archiving the last ready Agent now explains that saving will return the project to a
|
||||
pending state; saving that state clears `initialized` and leaves the unusable chat route.
|
||||
- Added a full Coding chat recovery state with a direct project-configuration action and no
|
||||
Composer or Conversation/runtime creation when no usable Agent exists.
|
||||
- Updated README with the accepted readiness and onboarding behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused Vitest: 5 files, 63 tests passed, covering readiness, App routing, MainLayout
|
||||
gating, Project Configuration onboarding/navigation, and Coding chat recovery.
|
||||
- Full `pnpm test`: 230 files / 1928 tests passed with 2 skipped, followed by the isolated
|
||||
pressure test passing 1/1.
|
||||
- `pnpm run typecheck`: passed after the final interaction changes.
|
||||
- `pnpm run lint:check`: passed with 0 errors; it retained 5 pre-existing warnings in
|
||||
`src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
|
||||
- `pnpm run build:vite`: passed after the final interaction changes; only the repository's
|
||||
existing Browserslist, dynamic-import, and chunk-size warnings were emitted.
|
||||
- Electron E2E `project-configuration-skills.spec.ts`: passed 1/1, including zero-model
|
||||
guidance, legacy `initialized: true` plus zero-Agent recovery, and labelled return to chat.
|
||||
- Playwright trace inspection showed an initial E2E failure was caused by that spec's later
|
||||
restrictive Host API stub, not product navigation; the navigation assertion now runs
|
||||
before the stub and passes.
|
||||
- Independent read-only review found the disabled-only recovery loop; after the targeted
|
||||
correction and test, the reviewer returned PASS.
|
||||
- `git diff --check`: passed.
|
||||
- With explicit human approval, rebuilt the unpushed `main` merge from a safety
|
||||
checkpoint and adopted the exact unchanged state of
|
||||
`.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md`; the rebuilt
|
||||
feature tree matches the checkpoint tree exactly.
|
||||
- `check_project_docs.py` and `check_doc_drift.py --task-id
|
||||
20260906-project-setup-ux-90fe6cf2` both passed after recovery.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- No required implementation follow-up remains.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/30-worklog/current-state.md`.
|
||||
Proposal: record that Coding Conversation readiness requires both persisted
|
||||
initialization and at least one enabled, unarchived, fully configured project Agent;
|
||||
all project-entry and chat-recovery surfaces share this invariant, and the first Agent
|
||||
setup is the explicit completion path.
|
||||
Evidence: shared readiness tests, Project Configuration onboarding tests, App/MainLayout
|
||||
route tests, Coding chat recovery tests, Electron E2E, successful full unit suite,
|
||||
typecheck, lint, production build, and independent review.
|
||||
Future impact: future project-entry, archival, or Agent-edit work must reuse the shared
|
||||
predicate rather than treating `initialized` as sufficient on its own.
|
||||
Semantic conflicts: none known; the rule preserves the existing no-default-Agent and
|
||||
project-owned Agent decisions.
|
||||
Human confirmation required: no, unless integration discovers a conflicting accepted
|
||||
product decision.
|
||||
@@ -126,7 +126,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
|
||||
- 新项目默认没有智能体;用户在项目内创建智能体时必须填写名称、预设头像或本地头像、职责说明和精确的 Provider 账号与默认模型。该选择以 `{ accountId, modelId, thinkingLevel }` 保存,不依赖运行时私有 Provider id。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;系统指令与技能属于高级设置。
|
||||
- Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。
|
||||
- 项目配置页的新增与已有智能体编辑统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。“可用模型”抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。智能体的默认模型只作为新对话的初始值;核心聊天页可为当前对话独立切换模型和思考强度,切换不会改写智能体默认值或其他对话。
|
||||
- 新项目可以暂时保留为待配置状态,但只有至少一个已启用、未归档且名称、头像、默认模型、角色与职责完整的项目智能体时,项目才可进入对话;旧配置即使错误保留了 `initialized: true`,也会回到项目配置完成这一步。配置页用顶部必需步骤和主操作强引导创建智能体,无模型时直接引导到模型设置;左上角提供带文字的确定性返回操作。聊天页遇到无可用智能体时显示可恢复空态,不展示不可用的输入框。
|
||||
- 项目配置页的新增与已有智能体编辑统一使用居中弹窗;首个智能体创建成功后会立即持久化并进入项目对话,后续新增与编辑先更新当前页面草稿,再点击底部“保存项目配置”统一持久化。“可用模型”抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。智能体的默认模型只作为新对话的初始值;核心聊天页可为当前对话独立切换模型和思考强度,切换不会改写智能体默认值或其他对话。
|
||||
- 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。
|
||||
- “可用技能”入口使用模块图标;点击技能后先展示目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。
|
||||
- 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。
|
||||
|
||||
@@ -56,3 +56,22 @@ export interface CodingProjectConfigSnapshot {
|
||||
config: CodingProjectConfig;
|
||||
knowledgeFiles: string[];
|
||||
}
|
||||
|
||||
export function isCodingProjectAgentReady(agent: CodingProjectAgent): boolean {
|
||||
return agent.enabled
|
||||
&& agent.archivedAt === null
|
||||
&& agent.name.trim().length > 0
|
||||
&& agent.avatarId.trim().length > 0
|
||||
&& agent.roleName.trim().length > 0
|
||||
&& agent.responsibility.mission.trim().length > 0
|
||||
&& agent.modelResolution === 'resolved'
|
||||
&& agent.model !== null;
|
||||
}
|
||||
|
||||
export function hasReadyCodingProjectAgent(config: CodingProjectConfig): boolean {
|
||||
return config.agents.some(isCodingProjectAgentReady);
|
||||
}
|
||||
|
||||
export function isCodingProjectConversationReady(config: CodingProjectConfig): boolean {
|
||||
return config.initialized && hasReadyCodingProjectAgent(config);
|
||||
}
|
||||
+7
-1
@@ -26,6 +26,7 @@ import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
|
||||
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
|
||||
import { resolveSupportedLanguage } from '../shared/language';
|
||||
import { isCodingProjectConversationReady } from '../shared/coding-project-contracts';
|
||||
import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect';
|
||||
|
||||
const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component })));
|
||||
@@ -229,7 +230,12 @@ function ProjectChatRoute() {
|
||||
const configResult = await loadProjectConfig(project.id);
|
||||
if (!cancelled) {
|
||||
setResolvedProjectKey(project.id);
|
||||
setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config?.initialized) ? 'chat' : 'config');
|
||||
setResolvedRoute(
|
||||
configResult.status === 'valid'
|
||||
&& Boolean(configResult.config && isCodingProjectConversationReady(configResult.config))
|
||||
? 'chat'
|
||||
: 'config',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SidebarPeekSource } from './sidebar-peek';
|
||||
import { isCodingProjectConversationReady } from '../../../shared/coding-project-contracts';
|
||||
|
||||
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
|
||||
|
||||
@@ -88,7 +89,11 @@ export function MainLayout() {
|
||||
if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined);
|
||||
}, [activeProject, isProgrammingModule, load]);
|
||||
|
||||
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
|
||||
const initializationBlocked = Boolean(
|
||||
activeProject
|
||||
&& (!config || !isCodingProjectConversationReady(config))
|
||||
&& !isInitializationSafeRoute,
|
||||
);
|
||||
return (
|
||||
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
|
||||
{/* Title bar: drag region on macOS, icon + controls on Windows */}
|
||||
@@ -121,8 +126,8 @@ export function MainLayout() {
|
||||
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
|
||||
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
|
||||
<LockKeyhole className="mx-auto h-10 w-10" />
|
||||
<h1 className="mt-4 text-2xl font-semibold">项目尚未初始化</h1>
|
||||
<p className="mt-3 text-sm font-medium text-muted-foreground">请先在项目配置中完成至少一个手动 Agent 的名称、模型和职责设置。</p>
|
||||
<h1 className="mt-4 text-2xl font-semibold">项目还差一步</h1>
|
||||
<p className="mt-3 text-pretty text-sm font-medium leading-6 text-muted-foreground">请先在项目配置中创建或恢复一个项目智能体,并完成名称、模型和职责设置。</p>
|
||||
<Button onClick={() => navigate('/project-config')} className="mt-6 border border-brand/20 bg-brand font-semibold text-primary-foreground"><Settings2 className="mr-2 h-4 w-4" />打开项目配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,10 @@ import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import type { ProjectType } from '../../../shared/project-config';
|
||||
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
|
||||
import {
|
||||
isCodingProjectConversationReady,
|
||||
type ProjectIdentityChoice,
|
||||
} from '../../../shared/coding-project-contracts';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type OpenDialogResult = {
|
||||
@@ -439,7 +442,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
return;
|
||||
}
|
||||
await setActiveProject(project.id);
|
||||
navigate(result.config.initialized
|
||||
navigate(isCodingProjectConversationReady(result.config)
|
||||
? '/chat'
|
||||
: projectConfigPath);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,12 @@ import { CodingConversationSidebar } from './CodingConversationSidebar';
|
||||
import { CodingConversationHeader } from './CodingConversationHeader';
|
||||
import { CodingConversationTimeline } from './CodingConversationTimeline';
|
||||
import { CodingInteractionPanel } from './CodingInteractionPanel';
|
||||
import { CodingProjectSetupEmptyState } from './CodingProjectSetupEmptyState';
|
||||
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
import {
|
||||
isCodingProjectAgentReady,
|
||||
isCodingProjectConversationReady,
|
||||
} from '../../../shared/coding-project-contracts';
|
||||
|
||||
export interface CodingChatPanelProps {
|
||||
navigationDraft?: string;
|
||||
@@ -158,8 +163,9 @@ export function CodingChatPanel({
|
||||
const submissionFlightsRef = useRef(new Set<string>());
|
||||
|
||||
const agents = useMemo(() => (
|
||||
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
|
||||
config?.agents.filter(isCodingProjectAgentReady) ?? []
|
||||
), [config]);
|
||||
const conversationReady = Boolean(config && isCodingProjectConversationReady(config));
|
||||
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
|
||||
const selectedConversation = conversations.find((conversation) => (
|
||||
conversation.id === selectedConversationId
|
||||
@@ -615,6 +621,10 @@ export function CodingChatPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (activeProject && config && !conversationReady && workspaceLoadState !== 'loading') {
|
||||
return <CodingProjectSetupEmptyState onOpenProjectSettings={onOpenProjectSettings} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel">
|
||||
<CodingConversationSidebar
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Bot, Settings2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function CodingProjectSetupEmptyState({
|
||||
onOpenProjectSettings,
|
||||
}: {
|
||||
onOpenProjectSettings?(): void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-0 flex-1 items-center justify-center bg-background p-6"
|
||||
data-testid="coding-chat-agent-setup-empty-state"
|
||||
>
|
||||
<div className="surface-card max-w-md rounded-2xl bg-background p-8 text-center shadow-float">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-soft text-brand shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<Bot className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-balance text-xl font-semibold">创建项目智能体后开始对话</h1>
|
||||
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
当前项目还没有可用的项目智能体。完成名称、模型和职责设置后,就可以发送消息。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-5 min-h-10 rounded-xl px-4 transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
onClick={onOpenProjectSettings}
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
创建或恢复项目智能体
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react';
|
||||
import { ArrowLeft, Blocks, Bot, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,10 +20,16 @@ import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-project';
|
||||
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
|
||||
import {
|
||||
hasReadyCodingProjectAgent,
|
||||
isCodingProjectAgentReady,
|
||||
isCodingProjectConversationReady,
|
||||
type ProjectIdentityChoice,
|
||||
} from '../../../shared/coding-project-contracts';
|
||||
|
||||
const EMPTY_FILES: string[] = [];
|
||||
type DrawerMode = 'models' | 'knowledge' | 'skills' | null;
|
||||
type ExitDestination = '/chat' | '/module-select' | '/models';
|
||||
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
|
||||
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
|
||||
type SkillInfo = {
|
||||
@@ -93,7 +99,8 @@ function SkillDetail({ skill }: { skill: SkillInfo }) {
|
||||
}
|
||||
|
||||
function AgentCard({ agent, onOpen }: { agent: CodingProjectAgent; onOpen: () => void }) {
|
||||
return <button type="button" data-testid={`project-agent-${agent.id}`} onClick={onOpen} className="config-motion-agent surface-card motion-press h-[260px] w-[210px] flex-none rounded-2xl border border-border/70 bg-background p-4 text-left shadow-none hover:shadow-soft"><img src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)} alt="" className="agent-motion-avatar mx-auto h-20 w-20 rounded-xl border border-border object-cover [image-rendering:pixelated]" /><div className="mt-4 flex items-center justify-between gap-2"><p className="truncate font-semibold">{agent.name || '未命名'}</p><Badge className="shrink-0 border border-brand/15 bg-brand-soft text-brand">可用</Badge></div><p className="mt-1 truncate text-xs font-semibold text-muted-foreground">{agent.roleName}</p><p className="mt-3 line-clamp-3 min-h-12 text-xs text-muted-foreground">{agent.responsibility.mission}</p><p className="mt-2 text-[11px] text-muted-foreground">{agent.skillIds.length} 个技能</p></button>;
|
||||
const ready = isCodingProjectAgentReady(agent);
|
||||
return <button type="button" data-testid={`project-agent-${agent.id}`} onClick={onOpen} className="config-motion-agent surface-card motion-press h-[260px] w-[210px] flex-none rounded-2xl border border-border/70 bg-background p-4 text-left shadow-none hover:shadow-soft"><img src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)} alt="" className="agent-motion-avatar mx-auto h-20 w-20 rounded-xl border border-border object-cover [image-rendering:pixelated]" /><div className="mt-4 flex items-center justify-between gap-2"><p className="truncate font-semibold">{agent.name || '未命名'}</p><Badge className={cn('shrink-0 border', ready ? 'border-brand/15 bg-brand-soft text-brand' : 'border-border/80 bg-surface-subtle text-muted-foreground')}>{ready ? '可用' : '待完成'}</Badge></div><p className="mt-1 truncate text-xs font-semibold text-muted-foreground">{agent.roleName}</p><p className="mt-3 line-clamp-3 min-h-12 text-xs text-muted-foreground">{agent.responsibility.mission}</p><p className="mt-2 text-[11px] text-muted-foreground">{agent.skillIds.length} 个技能</p></button>;
|
||||
}
|
||||
|
||||
function ModelCard({ model }: { model: CodingModelOption }) {
|
||||
@@ -111,10 +118,20 @@ function ModelCard({ model }: { model: CodingModelOption }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ModelList({ models }: { models: CodingModelOption[] }) {
|
||||
function ModelList({
|
||||
models,
|
||||
onOpenModelSettings,
|
||||
}: {
|
||||
models: CodingModelOption[];
|
||||
onOpenModelSettings: () => void;
|
||||
}) {
|
||||
if (models.length === 0) {
|
||||
return <div data-testid="project-model-empty-state" className="rounded-xl border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">
|
||||
当前没有可用模型,请先在模型设置中完成配置。智能体的默认模型在智能体配置中设置。
|
||||
<p className="text-pretty leading-6">当前没有可用模型。先完成模型配置,再从左侧项目入口回到这里创建智能体。</p>
|
||||
<Button type="button" className="mt-4 min-h-10 rounded-xl px-4 transition-transform duration-150 ease-out active:scale-[0.96]" onClick={onOpenModelSettings}>
|
||||
<Cpu className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
前往模型设置
|
||||
</Button>
|
||||
</div>;
|
||||
}
|
||||
return <div data-testid="project-model-list" className="space-y-3">
|
||||
@@ -153,6 +170,7 @@ export function ProjectConfiguration() {
|
||||
const [identityError, setIdentityError] = useState<string | null>(null);
|
||||
const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pendingExitDestination, setPendingExitDestination] = useState<ExitDestination | null>(null);
|
||||
const [partnerDialogOpen, setPartnerDialogOpen] = useState(false);
|
||||
const [archiveAgentId, setArchiveAgentId] = useState<string | null>(null);
|
||||
const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -197,11 +215,47 @@ export function ProjectConfiguration() {
|
||||
const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null;
|
||||
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
|
||||
const playSaveConfirmation = () => undefined;
|
||||
const handleBack = () => navigate(-1);
|
||||
|
||||
if (!activeProject) return <main data-testid="project-configuration-empty-state" className="flex min-h-full items-start bg-background p-8 text-foreground"><p className="pt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p></main>;
|
||||
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
|
||||
|
||||
const draftHasReadyAgent = hasReadyCodingProjectAgent(draft);
|
||||
const storedConversationReady = Boolean(stored && isCodingProjectConversationReady(stored));
|
||||
const hasUnsavedChanges = Boolean(stored && JSON.stringify(stored) !== JSON.stringify(draft));
|
||||
const backDestination: ExitDestination = storedConversationReady ? '/chat' : '/module-select';
|
||||
const backLabel = storedConversationReady ? '返回对话' : '稍后设置';
|
||||
const pendingAgent = draft.agents.find((agent) => (
|
||||
agent.enabled && !agent.archivedAt && !isCodingProjectAgentReady(agent)
|
||||
));
|
||||
const setupActionLabel = modelOptions.length === 0
|
||||
? '先配置模型'
|
||||
: pendingAgent
|
||||
? '完成智能体配置'
|
||||
: '创建项目智能体';
|
||||
const archiveAgent = draft.agents.find((agent) => agent.id === archiveAgentId);
|
||||
const archiveRemovesLastReadyAgent = Boolean(
|
||||
archiveAgent
|
||||
&& isCodingProjectAgentReady(archiveAgent)
|
||||
&& !draft.agents.some((agent) => agent.id !== archiveAgentId && isCodingProjectAgentReady(agent)),
|
||||
);
|
||||
const requestNavigation = (destination: ExitDestination) => {
|
||||
if (hasUnsavedChanges) {
|
||||
setPendingExitDestination(destination);
|
||||
return;
|
||||
}
|
||||
navigate(destination);
|
||||
};
|
||||
const handleBack = () => requestNavigation(backDestination);
|
||||
const openModelSettings = () => requestNavigation('/models');
|
||||
const openAgentCreation = () => {
|
||||
if (modelOptions.length === 0) {
|
||||
openModelSettings();
|
||||
return;
|
||||
}
|
||||
setActiveAgentId(pendingAgent?.id ?? null);
|
||||
setPartnerDialogOpen(true);
|
||||
};
|
||||
|
||||
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
|
||||
const handlePartnerDialogOpenChange = (open: boolean) => {
|
||||
setPartnerDialogOpen(open);
|
||||
@@ -221,7 +275,22 @@ export function ProjectConfiguration() {
|
||||
skillIds: input.skillIds,
|
||||
responsibility: { mission: input.responsibility, owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
};
|
||||
setDraft((current) => current ? { ...current, agents: [...current.agents, agent] } : current);
|
||||
const nextDraft = { ...draft, agents: [...draft.agents, agent] };
|
||||
if (!draftHasReadyAgent) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await save(activeProject.id, { ...nextDraft, initialized: true });
|
||||
setDraft(structuredClone(saved));
|
||||
await reloadWorkspace();
|
||||
playSaveConfirmation();
|
||||
toast.success('项目智能体已创建');
|
||||
navigate('/chat');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDraft(nextDraft);
|
||||
};
|
||||
const handleUpdatePartner = async (input: AgentCreationInput) => {
|
||||
if (!activeAgentId) return;
|
||||
@@ -258,12 +327,26 @@ export function ProjectConfiguration() {
|
||||
toast.error('请补全智能体名称、头像、默认模型和职责说明;名称需在当前项目内唯一。');
|
||||
return;
|
||||
}
|
||||
const conversationReady = hasReadyCodingProjectAgent(draft);
|
||||
if (!conversationReady && !storedConversationReady) {
|
||||
openAgentCreation();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await save(activeProject.id, { ...draft, initialized: true });
|
||||
const saved = await save(activeProject.id, { ...draft, initialized: conversationReady });
|
||||
setDraft(structuredClone(saved));
|
||||
await reloadWorkspace();
|
||||
playSaveConfirmation();
|
||||
toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成');
|
||||
if (!conversationReady) {
|
||||
toast.success('项目已保存,创建智能体后即可开始对话');
|
||||
navigate('/module-select');
|
||||
} else if (!storedConversationReady) {
|
||||
toast.success('项目初始化完成');
|
||||
navigate('/chat');
|
||||
} else {
|
||||
toast.success('项目配置已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -336,11 +419,36 @@ export function ProjectConfiguration() {
|
||||
return <main className="-m-5 flex min-h-[calc(100%+2.5rem)] min-h-0 flex-col overflow-auto bg-background p-4 text-foreground sm:-m-6 sm:min-h-[calc(100%+3rem)]" data-testid="project-configuration-page">
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col gap-3">
|
||||
<div className="config-motion-title flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="icon" data-testid="project-configuration-back-button" aria-label="返回" title="返回" onClick={handleBack} className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-surface-subtle hover:text-foreground">
|
||||
<Button type="button" variant="ghost" data-testid="project-configuration-back-button" aria-label={backLabel} title={backLabel} onClick={handleBack} className="min-h-10 shrink-0 rounded-xl px-3 text-muted-foreground transition-transform duration-150 ease-out hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="ml-2 font-semibold">{backLabel}</span>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">项目配置</h1>
|
||||
</div>
|
||||
{!draftHasReadyAgent ? (
|
||||
<section data-testid="project-agent-setup-callout" className="surface-card flex flex-col gap-4 rounded-2xl bg-brand-soft/60 p-5 shadow-[0_0_0_1px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-background text-brand shadow-[0_0_0_1px_rgba(0,0,0,0.06)]" aria-hidden="true">
|
||||
<Bot className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-balance text-lg font-semibold">{draft.agents.length === 0 ? '项目已创建,还差 1 步' : '项目需要一个可用智能体'}</h2>
|
||||
<Badge className="border border-brand/20 bg-background text-brand">必需</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-pretty text-sm font-medium leading-6 text-muted-foreground">
|
||||
{modelOptions.length === 0
|
||||
? '先配置一个可用模型,再创建项目智能体即可开始对话。'
|
||||
: '创建一个项目智能体并完成名称、默认模型和职责设置,即可开始对话。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" data-testid="project-agent-setup-primary" className="min-h-10 shrink-0 rounded-xl px-4 transition-transform duration-150 ease-out active:scale-[0.96]" onClick={openAgentCreation}>
|
||||
{modelOptions.length === 0 ? <Cpu className="mr-2 h-4 w-4" aria-hidden="true" /> : <Plus className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{setupActionLabel}
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
<section data-testid="project-identity-card" className="surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft">
|
||||
{draft.projectId ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -456,7 +564,31 @@ export function ProjectConfiguration() {
|
||||
)}
|
||||
</section>
|
||||
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /></section>
|
||||
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</section>
|
||||
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">项目智能体</h2>
|
||||
{!draftHasReadyAgent ? <Badge className="border border-brand/20 bg-brand-soft text-brand">必需</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-pretty text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p>
|
||||
</div>
|
||||
{draftHasReadyAgent ? (
|
||||
<Button type="button" size="icon" className="h-10 w-10 rounded-xl border border-brand/20 bg-brand-soft text-brand transition-transform duration-150 ease-out active:scale-[0.96]" aria-label="创建智能体" title="创建智能体" onClick={openAgentCreation}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">
|
||||
{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}
|
||||
{draft.agents.every((agent) => agent.archivedAt) ? (
|
||||
<div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">
|
||||
创建或恢复一个项目智能体后,才能进入项目对话。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="min-h-10 border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}
|
||||
</section>
|
||||
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
|
||||
<div data-testid="project-configuration-primary-actions" className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-start sm:justify-end">
|
||||
@@ -469,7 +601,23 @@ export function ProjectConfiguration() {
|
||||
buttonSize="sm"
|
||||
/>
|
||||
) : null}
|
||||
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-10 w-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground sm:w-auto"><Check className="mr-2 h-4 w-4" />{saving ? '保存中…' : draft.initialized ? '保存项目配置' : '确认并完成初始化'}</Button>
|
||||
{!draftHasReadyAgent && !storedConversationReady ? (
|
||||
<Button type="button" onClick={openAgentCreation} disabled={saving} className="project-save-button h-10 w-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground transition-transform duration-150 ease-out active:scale-[0.96] sm:w-auto">
|
||||
{modelOptions.length === 0 ? <Cpu className="mr-2 h-4 w-4" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||
{setupActionLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-10 w-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground transition-transform duration-150 ease-out active:scale-[0.96] sm:w-auto">
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{saving
|
||||
? '保存中…'
|
||||
: !draftHasReadyAgent
|
||||
? '保存并稍后设置'
|
||||
: storedConversationReady
|
||||
? '保存项目配置'
|
||||
: '保存并进入对话'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,24 +632,39 @@ export function ProjectConfiguration() {
|
||||
onUpdate={handleUpdatePartner}
|
||||
onArchive={activeAgent && !activeAgent.builtIn ? () => setArchiveAgentId(activeAgent.id) : undefined}
|
||||
title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'}
|
||||
submitLabel={activeAgent ? '保存智能体' : '创建智能体'}
|
||||
submitLabel={activeAgent ? '保存智能体' : draftHasReadyAgent ? '创建智能体' : '创建并进入对话'}
|
||||
/>
|
||||
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className="glass-surface flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none">
|
||||
{drawerMode === 'models' ? <DrawerFrame title="可用模型" desc="查看已配置的模型;智能体默认模型在智能体配置中设置。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} /></DrawerFrame> : null}
|
||||
{drawerMode === 'models' ? <DrawerFrame title="可用模型" desc="查看已配置的模型;智能体默认模型在智能体配置中设置。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} onOpenModelSettings={openModelSettings} /></DrawerFrame> : null}
|
||||
{drawerMode === 'knowledge' ? <DrawerFrame title="知识文件" desc="文件保存在当前项目的 knowledge/ 目录中。" onClose={() => setDrawerMode(null)}><input ref={knowledgeInputRef} type="file" className="sr-only" onChange={(event) => { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} /><Button className="w-full border border-foreground/15 bg-brand-soft text-foreground" onClick={() => knowledgeInputRef.current?.click()}><Upload className="mr-2 h-4 w-4" />上传知识文件</Button><div className="mt-5 space-y-3">{knowledge.length ? knowledge.map((file) => <div key={file} className="rounded-lg border border-foreground/15 bg-white p-3 font-semibold">{file}</div>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium">当前还没有知识文件。</div>}</div></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && activeSkill ? <DrawerFrame title={`${activeSkill.name} · 技能详情`} desc="查看技能目录结构和主文件 SKILL.md。" onClose={() => setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"><SkillDetail skill={activeSkill} /></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && !activeSkill ? <DrawerFrame title="可用技能" desc="查看技能目录与主文件;在智能体配置中启用所需技能。" onClose={() => setDrawerMode(null)}><div className="space-y-3">{skills.length > 0 ? skills.map((skill) => <button key={skill.id} type="button" data-testid={`skill-card-${skill.id}`} aria-label={`查看技能详情:${skill.name}`} onClick={() => setActiveSkillId(skill.id)} className="w-full rounded-lg border border-foreground/15 bg-white p-3 text-left transition hover:border-brand/40 hover:bg-brand-soft/30"><div className="flex items-start gap-3"><Blocks className="mt-0.5 h-4 w-4 shrink-0 text-brand" /><span className="min-w-0 flex-1"><span className="block font-semibold">{skill.name}</span><span className="mt-1 block text-sm text-muted-foreground">{skill.description}</span><span className="mt-2 block text-[11px] font-medium text-brand">查看目录与 SKILL.md</span></span><ArrowLeft className="mt-0.5 h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" /></div></button>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">当前没有可查看的技能。</div>}</div></DrawerFrame> : null}
|
||||
</SheetContent></Sheet>
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveAgentId)}
|
||||
title={`归档智能体“${draft.agents.find((agent) => agent.id === archiveAgentId)?.name || '未命名智能体'}”?`}
|
||||
message="归档后将停止提供新的使用入口,但会保留智能体配置和全部对话记录;你可以之后在智能体列表底部恢复。"
|
||||
title={`归档智能体“${archiveAgent?.name || '未命名智能体'}”?`}
|
||||
message={archiveRemovesLastReadyAgent
|
||||
? '这是当前最后一个可用智能体。归档并保存后,项目将进入待配置状态,无法开始新对话;智能体配置和全部对话记录仍会保留。'
|
||||
: '归档后将停止提供新的使用入口,但会保留智能体配置和全部对话记录;你可以之后在智能体列表底部恢复。'}
|
||||
confirmLabel="确认归档"
|
||||
cancelLabel="取消"
|
||||
onCancel={() => setArchiveAgentId(null)}
|
||||
onConfirm={handleArchiveAgent}
|
||||
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pendingExitDestination !== null}
|
||||
title="放弃未保存的项目配置?"
|
||||
message="离开后,本页尚未保存的智能体新增、编辑或归档操作会丢失。"
|
||||
confirmLabel={pendingExitDestination === '/models' ? '前往模型设置' : backLabel}
|
||||
cancelLabel="继续编辑"
|
||||
onCancel={() => setPendingExitDestination(null)}
|
||||
onConfirm={() => {
|
||||
const destination = pendingExitDestination;
|
||||
setPendingExitDestination(null);
|
||||
if (destination) navigate(destination);
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
title={`删除项目“${activeProject.name}”?`}
|
||||
|
||||
@@ -35,7 +35,11 @@ test.describe('Project configuration skills', () => {
|
||||
await expect(page.getByTestId('project-configuration-actions')).toBeVisible();
|
||||
const actionBarBottomGap = await page.getByTestId('project-configuration-actions').evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom);
|
||||
expect(actionBarBottomGap).toBeLessThan(40);
|
||||
await expect(page.getByRole('button', { name: '创建智能体' })).toBeVisible();
|
||||
const setupCallout = page.getByTestId('project-agent-setup-callout');
|
||||
await expect(setupCallout).toContainText('项目已创建,还差 1 步');
|
||||
await expect(setupCallout).toContainText('必需');
|
||||
await expect(page.getByTestId('project-agent-setup-primary')).toHaveText('先配置模型');
|
||||
await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置');
|
||||
|
||||
for (const [resourceId, title] of [
|
||||
['resource-card-models', '可用模型'],
|
||||
@@ -48,6 +52,7 @@ test.describe('Project configuration skills', () => {
|
||||
if (resourceId === 'resource-card-models') {
|
||||
await expect(drawer.getByRole('combobox')).toHaveCount(0);
|
||||
await expect(drawer.locator('[data-testid="project-model-list"], [data-testid="project-model-empty-state"]')).toHaveCount(1);
|
||||
await expect(drawer.getByRole('button', { name: '前往模型设置' })).toBeVisible();
|
||||
}
|
||||
const closeButton = drawer.getByRole('button', { name: '关闭' });
|
||||
const closeGeometry = await closeButton.evaluate((button) => {
|
||||
@@ -73,6 +78,15 @@ test.describe('Project configuration skills', () => {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await mkdir(path.join(parentPath, '.makelore'), { recursive: true });
|
||||
await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({
|
||||
...createCodingProjectConfigV2(now, 'interactive_ai_app'),
|
||||
initialized: true,
|
||||
agents: [],
|
||||
}, null, 2));
|
||||
await page.evaluate(() => { window.location.hash = '#/chat'; });
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect(page.getByTestId('project-agent-setup-callout')).toBeVisible();
|
||||
|
||||
await writeFile(path.join(parentPath, '.makelore', 'project.json'), JSON.stringify({
|
||||
...createCodingProjectConfigV2(now, 'interactive_ai_app'),
|
||||
initialized: true,
|
||||
@@ -96,6 +110,11 @@ test.describe('Project configuration skills', () => {
|
||||
}, null, 2));
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect(page.getByTestId('project-configuration-back-button')).toHaveAccessibleName('返回对话');
|
||||
await page.getByTestId('project-configuration-back-button').click();
|
||||
await expect(page.getByTestId('chat-operation-page')).toBeVisible();
|
||||
await page.evaluate(() => { window.location.hash = '#/project-config'; });
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
|
||||
await page.getByTestId('resource-card-skills').click();
|
||||
await expect(page.getByTestId('superpowers-card')).toHaveCount(0);
|
||||
@@ -163,6 +182,7 @@ test.describe('Project configuration skills', () => {
|
||||
await expect(avatarDialog).toHaveCount(0);
|
||||
await expect(maintenanceDialog.getByAltText('当前上传头像')).toBeVisible();
|
||||
await expect(page.getByText('完成配置')).toHaveCount(0);
|
||||
await maintenanceDialog.getByRole('button', { name: '取消' }).click();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rm(parentPath, { recursive: true, force: true });
|
||||
|
||||
@@ -6,6 +6,10 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useUserSyncStore } from '@/stores/user-sync';
|
||||
import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
|
||||
import type { CodingProjectAgent } from '@/types/coding-project';
|
||||
|
||||
vi.mock('@/components/layout/MainLayout', () => ({
|
||||
MainLayout: () => (
|
||||
@@ -39,11 +43,41 @@ vi.mock('@/pages/Settings', () => ({
|
||||
Settings: () => <div>Global settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/ProjectConfiguration', () => ({
|
||||
ProjectConfiguration: () => <div>Project configuration</div>,
|
||||
}));
|
||||
|
||||
const readyAgent: CodingProjectAgent = {
|
||||
id: 'agent-1',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '项目智能体',
|
||||
name: 'Builder',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' },
|
||||
modelResolution: 'resolved',
|
||||
skillIds: [],
|
||||
responsibility: {
|
||||
mission: '完成项目工作',
|
||||
owns: [],
|
||||
boundaries: [],
|
||||
collaborators: [],
|
||||
principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
createdAt: '2026-09-06T00:00:00.000Z',
|
||||
updatedAt: '2026-09-06T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('App programming provider initialization gate', () => {
|
||||
const initProviders = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
codingWorkspaceStore.setState(codingWorkspaceStore.getInitialState(), true);
|
||||
useProjectConfigStore.setState(useProjectConfigStore.getInitialState(), true);
|
||||
useSettingsStore.setState({
|
||||
setupComplete: true,
|
||||
init: vi.fn(),
|
||||
@@ -95,6 +129,56 @@ describe('App programming provider initialization gate', () => {
|
||||
await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('redirects a legacy initialized project without a usable Agent to configuration', async () => {
|
||||
const project = {
|
||||
id: 'local-project',
|
||||
path: '/tmp/local-project',
|
||||
name: 'local-project',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastOpenedAt: '',
|
||||
};
|
||||
const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [] };
|
||||
codingWorkspaceStore.setState({
|
||||
activeProjectId: project.id,
|
||||
activeProject: project,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
useProjectConfigStore.setState({
|
||||
load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }),
|
||||
});
|
||||
|
||||
await renderAt('/chat');
|
||||
|
||||
expect(await screen.findByText('Project configuration')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Programming workspace')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens chat when the project has a fully configured Agent', async () => {
|
||||
const project = {
|
||||
id: 'local-project',
|
||||
path: '/tmp/local-project',
|
||||
name: 'local-project',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastOpenedAt: '',
|
||||
};
|
||||
const config = { ...createCodingProjectConfigV2(), initialized: true, agents: [readyAgent] };
|
||||
codingWorkspaceStore.setState({
|
||||
activeProjectId: project.id,
|
||||
activeProject: project,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
useProjectConfigStore.setState({
|
||||
load: vi.fn().mockResolvedValue({ status: 'valid', config, knowledgeFiles: [] }),
|
||||
});
|
||||
|
||||
await renderAt('/chat');
|
||||
|
||||
expect(await screen.findByText('Programming workspace')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Project configuration')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects an orphaned token without a user identity away from a direct workspace route', async () => {
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
|
||||
@@ -80,8 +80,8 @@ const agent: CodingProjectAgent = {
|
||||
pinned: true,
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
model: null,
|
||||
modelResolution: 'required',
|
||||
model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' },
|
||||
modelResolution: 'resolved',
|
||||
};
|
||||
const config: CodingProjectConfig = {
|
||||
schemaVersion: 2,
|
||||
@@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('shows an actionable setup state when the active project has no usable Agent', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([]) });
|
||||
projectApi.conversations.mockResolvedValue([]);
|
||||
const onOpenProjectSettings = vi.fn();
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
|
||||
render(<CodingChatPanel onOpenProjectSettings={onOpenProjectSettings} />);
|
||||
|
||||
expect(await screen.findByTestId('coding-chat-agent-setup-empty-state')).toBeVisible();
|
||||
expect(screen.getByText('创建项目智能体后开始对话')).toBeVisible();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
expect(conversationApi.snapshot).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建或恢复项目智能体' }));
|
||||
expect(onOpenProjectSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
CodingProjectAgent,
|
||||
CodingProjectConfig,
|
||||
} from '../../shared/coding-project-contracts';
|
||||
import {
|
||||
hasReadyCodingProjectAgent,
|
||||
isCodingProjectAgentReady,
|
||||
isCodingProjectConversationReady,
|
||||
} from '../../shared/coding-project-contracts';
|
||||
|
||||
const createdAt = '2026-09-06T00:00:00.000Z';
|
||||
|
||||
function createAgent(overrides: Partial<CodingProjectAgent> = {}): CodingProjectAgent {
|
||||
return {
|
||||
id: 'agent-1',
|
||||
avatarId: 'spark',
|
||||
roleName: '编程助手',
|
||||
name: '小洛',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
skillIds: [],
|
||||
responsibility: {
|
||||
mission: '帮助完成项目',
|
||||
owns: [],
|
||||
boundaries: [],
|
||||
collaborators: [],
|
||||
principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
model: {
|
||||
accountId: 'account-1',
|
||||
modelId: 'model-1',
|
||||
thinkingLevel: 'off',
|
||||
},
|
||||
modelResolution: 'resolved',
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(overrides: Partial<CodingProjectConfig> = {}): CodingProjectConfig {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
projectType: 'custom',
|
||||
initialized: true,
|
||||
agents: [createAgent()],
|
||||
knowledgeDirectory: 'knowledge',
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('coding project Conversation readiness', () => {
|
||||
it('accepts an enabled, unarchived, fully configured Agent', () => {
|
||||
const agent = createAgent();
|
||||
const config = createConfig({ agents: [agent] });
|
||||
|
||||
expect(isCodingProjectAgentReady(agent)).toBe(true);
|
||||
expect(hasReadyCodingProjectAgent(config)).toBe(true);
|
||||
expect(isCodingProjectConversationReady(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a project with no Agents', () => {
|
||||
const config = createConfig({ agents: [] });
|
||||
|
||||
expect(hasReadyCodingProjectAgent(config)).toBe(false);
|
||||
expect(isCodingProjectConversationReady(config)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['disabled', { enabled: false }],
|
||||
['archived', { archivedAt: createdAt }],
|
||||
] satisfies Array<[string, Partial<CodingProjectAgent>]>)('rejects a %s Agent', (_label, overrides) => {
|
||||
const agent = createAgent(overrides);
|
||||
|
||||
expect(isCodingProjectAgentReady(agent)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an Agent whose model still requires resolution', () => {
|
||||
const agent = createAgent({ model: null, modelResolution: 'required' });
|
||||
|
||||
expect(isCodingProjectAgentReady(agent)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an Agent with a resolved but missing model', () => {
|
||||
const agent = createAgent({ model: null });
|
||||
|
||||
expect(isCodingProjectAgentReady(agent)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an Agent with an empty responsibility mission', () => {
|
||||
const agent = createAgent({
|
||||
responsibility: {
|
||||
...createAgent().responsibility,
|
||||
mission: ' ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(isCodingProjectAgentReady(agent)).toBe(false);
|
||||
});
|
||||
|
||||
it('requires the project to be initialized', () => {
|
||||
const config = createConfig({ initialized: false });
|
||||
|
||||
expect(hasReadyCodingProjectAgent(config)).toBe(true);
|
||||
expect(isCodingProjectConversationReady(config)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,34 @@ import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
|
||||
import type { CodingProjectAgent } from '@/types/coding-project';
|
||||
|
||||
const learningIpcMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const readyAgent: CodingProjectAgent = {
|
||||
id: 'agent-1',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '项目智能体',
|
||||
name: 'Builder',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: { accountId: 'account-1', modelId: 'model-1', thinkingLevel: 'off' },
|
||||
modelResolution: 'resolved',
|
||||
skillIds: [],
|
||||
responsibility: {
|
||||
mission: '完成项目工作',
|
||||
owns: [],
|
||||
boundaries: [],
|
||||
collaborators: [],
|
||||
principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
createdAt: '2026-09-06T00:00:00.000Z',
|
||||
updatedAt: '2026-09-06T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mock('@/components/layout/Sidebar', () => ({
|
||||
Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => (
|
||||
<aside data-testid="sidebar-stub" data-sidebar-collapsed={sidebarCollapsedOverride === undefined ? 'unset' : String(sidebarCollapsedOverride)} />
|
||||
@@ -105,6 +130,34 @@ describe('MainLayout module isolation', () => {
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
|
||||
});
|
||||
|
||||
it('keeps the gate when a legacy config says initialized but has no usable Agent', () => {
|
||||
const project = codingWorkspaceStore.getState().activeProject!;
|
||||
useProjectConfigStore.setState({
|
||||
configsByProjectId: {
|
||||
[project.id]: { ...createCodingProjectConfigV2(), initialized: true, agents: [] },
|
||||
},
|
||||
});
|
||||
|
||||
renderLayout('/chat');
|
||||
|
||||
expect(screen.getByTestId('project-initialization-gate')).toBeVisible();
|
||||
expect(screen.getByText('项目还差一步')).toBeVisible();
|
||||
});
|
||||
|
||||
it('allows programming routes when the project has a fully configured Agent', () => {
|
||||
const project = codingWorkspaceStore.getState().activeProject!;
|
||||
useProjectConfigStore.setState({
|
||||
configsByProjectId: {
|
||||
[project.id]: { ...createCodingProjectConfigV2(), initialized: true, agents: [readyAgent] },
|
||||
},
|
||||
});
|
||||
|
||||
renderLayout('/chat');
|
||||
|
||||
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('route-content')).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the canonical plugin workspace outside the local project initialization gate', () => {
|
||||
renderLayout('/plugins');
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProjectConfiguration } from '@/pages/ProjectConfiguration';
|
||||
import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import type { ProviderAccount } from '@/lib/providers';
|
||||
import type { CodingProjectAgent, CodingProjectConfig, CodingProjectSummary } from '@/types/coding-project';
|
||||
import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config';
|
||||
|
||||
const getCodingSkillsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/coding-product-tools', () => ({
|
||||
getCodingSkills: (...args: unknown[]) => getCodingSkillsMock(...args),
|
||||
}));
|
||||
|
||||
const NOW = '2026-09-06T00:00:00.000Z';
|
||||
const project: CodingProjectSummary = {
|
||||
id: 'project-1',
|
||||
path: 'D:/projects/project-1',
|
||||
name: 'Onboarding project',
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
lastOpenedAt: NOW,
|
||||
};
|
||||
|
||||
const configuredAccount: ProviderAccount = {
|
||||
id: 'account-1',
|
||||
vendorId: 'custom',
|
||||
label: 'Test account',
|
||||
authMode: 'api_key',
|
||||
model: 'model-1',
|
||||
fallbackModels: [],
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
|
||||
const readyAgent: CodingProjectAgent = {
|
||||
id: 'agent-1',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '项目智能体',
|
||||
name: 'Builder',
|
||||
builtIn: false,
|
||||
enabled: true,
|
||||
model: { accountId: configuredAccount.id, modelId: configuredAccount.model!, thinkingLevel: 'off' },
|
||||
modelResolution: 'resolved',
|
||||
skillIds: [],
|
||||
responsibility: {
|
||||
mission: '完成项目工作',
|
||||
owns: [],
|
||||
boundaries: [],
|
||||
collaborators: [],
|
||||
principles: [],
|
||||
},
|
||||
prompt: '',
|
||||
archivedAt: null,
|
||||
pinned: false,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location-path">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
function renderConfiguration(config: CodingProjectConfig) {
|
||||
useProjectConfigStore.setState({
|
||||
configsByProjectId: { [project.id]: config },
|
||||
knowledgeByProjectId: { [project.id]: [] },
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/project-config']}>
|
||||
<Routes>
|
||||
<Route path="/project-config" element={<ProjectConfiguration />} />
|
||||
<Route path="/models" element={<LocationProbe />} />
|
||||
<Route path="/chat" element={<LocationProbe />} />
|
||||
<Route path="/module-select" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function setProviderModels() {
|
||||
useProviderStore.setState({
|
||||
accounts: [configuredAccount],
|
||||
vendors: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe('project configuration onboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCodingSkillsMock.mockResolvedValue({ skills: [] });
|
||||
|
||||
const load = vi.fn().mockResolvedValue(undefined);
|
||||
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshProviderSnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
codingWorkspaceStore.setState({
|
||||
activeProject: project,
|
||||
projects: [project],
|
||||
conversations: [],
|
||||
load: reloadWorkspace,
|
||||
});
|
||||
useProjectConfigStore.setState({ load });
|
||||
useProviderStore.setState({
|
||||
accounts: [],
|
||||
vendors: [],
|
||||
refreshProviderSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it('strongly guides a project without models to model settings', async () => {
|
||||
renderConfiguration(createCodingProjectConfigV2(NOW));
|
||||
|
||||
expect(await screen.findByTestId('project-agent-setup-callout')).toHaveTextContent('必需');
|
||||
expect(screen.getByTestId('project-agent-setup-primary')).toHaveTextContent('先配置模型');
|
||||
expect(screen.getByTestId('project-configuration-back-button')).toHaveAccessibleName('稍后设置');
|
||||
|
||||
fireEvent.click(screen.getByTestId('project-agent-setup-primary'));
|
||||
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/models');
|
||||
});
|
||||
|
||||
it('saves the first Agent as initialized and enters chat after creation', async () => {
|
||||
setProviderModels();
|
||||
const savedConfig = vi.fn((_projectId: string, nextConfig: CodingProjectConfig) => Promise.resolve(nextConfig));
|
||||
const reloadWorkspace = vi.fn().mockResolvedValue(undefined);
|
||||
useProjectConfigStore.setState({ save: savedConfig });
|
||||
codingWorkspaceStore.setState({ load: reloadWorkspace });
|
||||
|
||||
renderConfiguration(createCodingProjectConfigV2(NOW));
|
||||
fireEvent.click(await screen.findByTestId('project-agent-setup-primary'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '创建项目智能体' });
|
||||
fireEvent.change(within(dialog).getByLabelText(/智能体名称/), { target: { value: 'Builder' } });
|
||||
fireEvent.change(within(dialog).getByLabelText(/职责说明/), { target: { value: '完成项目工作' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建并进入对话' }));
|
||||
|
||||
await waitFor(() => expect(savedConfig).toHaveBeenCalledTimes(1));
|
||||
expect(savedConfig).toHaveBeenCalledWith(project.id, expect.objectContaining({
|
||||
initialized: true,
|
||||
agents: [expect.objectContaining({
|
||||
name: 'Builder',
|
||||
modelResolution: 'resolved',
|
||||
})],
|
||||
}));
|
||||
expect(reloadWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
||||
});
|
||||
|
||||
it('labels the back action and returns a complete project to chat', async () => {
|
||||
setProviderModels();
|
||||
|
||||
renderConfiguration({
|
||||
...createCodingProjectConfigV2(NOW),
|
||||
initialized: true,
|
||||
agents: [readyAgent],
|
||||
});
|
||||
|
||||
const backButton = await screen.findByTestId('project-configuration-back-button');
|
||||
expect(backButton).toHaveAccessibleName('返回对话');
|
||||
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
||||
});
|
||||
|
||||
it('offers a new Agent when the project contains only disabled Agents', async () => {
|
||||
setProviderModels();
|
||||
|
||||
renderConfiguration({
|
||||
...createCodingProjectConfigV2(NOW),
|
||||
initialized: false,
|
||||
agents: [{ ...readyAgent, enabled: false }],
|
||||
});
|
||||
|
||||
const setupButton = await screen.findByTestId('project-agent-setup-primary');
|
||||
expect(setupButton).toHaveTextContent('创建项目智能体');
|
||||
fireEvent.click(setupButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '创建项目智能体' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('confirms before the labelled back action discards Agent edits', async () => {
|
||||
setProviderModels();
|
||||
|
||||
renderConfiguration({
|
||||
...createCodingProjectConfigV2(NOW),
|
||||
initialized: true,
|
||||
agents: [readyAgent],
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByTestId(`project-agent-${readyAgent.id}`));
|
||||
const editor = await screen.findByRole('dialog', { name: `${readyAgent.name} · 编辑智能体` });
|
||||
fireEvent.change(within(editor).getByLabelText(/智能体名称/), { target: { value: 'Builder 2' } });
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存智能体' }));
|
||||
await waitFor(() => expect(editor).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('project-configuration-back-button'));
|
||||
const confirm = await screen.findByRole('dialog', { name: '放弃未保存的项目配置?' });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '返回对话' }));
|
||||
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/chat');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user