From 4b07f24d8a20f1da716298c8c7b1bca137b0d9af Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 5 Nov 2026 15:05:08 +0800 Subject: [PATCH 1/6] feat(forge): open inline comments from a hover plus and restyle the checks mark Show a comment plus beside the line number of the hovered diff line. Pressing it selects the line, dragging extends the range on that side, and releasing opens the editor, replacing the separate select and plus clicks for mouse users. Touch and keyboard users keep the existing path through the line number. Two-column code reserves an empty lane for the plus, so the number and the code stay reachable. Draw the Session row's checks outcome inside the change request glyph as a filled head branch end, smaller and without the ring, so it cannot drift from the glyph's outline. Refs #7937 --- apps/web/tests/forge-panel.e2e.ts | 79 ++++++++++++++++++- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangesTab.module.css | 5 ++ .../client-ui-forge/src/client/DiffFile.tsx | 39 +++++++-- .../src/client/ForgeTab.module.css | 13 +++ .../src/client/ReviewComposer.module.css | 6 +- .../src/client/SessionList.module.css | 25 ------ .../src/client/SessionRowForge.tsx | 7 +- .../client-ui-forge/src/client/glyphs.tsx | 18 ++++- .../tests/diff-review.client.spec.tsx | 38 +++++++++ .../tests/session-list.client.spec.tsx | 6 +- 13 files changed, 196 insertions(+), 48 deletions(-) diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 9985639f6d..05c8e6bc10 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -891,10 +891,13 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() expect(await resizeTree.getAttribute('aria-valuenow')).toBe(rightWidth) for (const side of ['old', 'new'] as const) { - const firstLine = await panelFile.locator(`[data-review-code][data-${side}-line="2"]`).boundingBox() + const firstCode = panelFile.locator(`[data-review-code][data-${side}-line="2"]`) + const firstLine = await firstCode.boundingBox() const lastLine = await panelFile.locator(`[data-review-code][data-${side}-line="4"]`).boundingBox() if (firstLine === null || lastLine === null) throw new Error('Review text must be visible') - await page.mouse.move(firstLine.x + 7, firstLine.y + firstLine.height / 2) + // Press on the first character, past the lane where the hovered row shows its comment plus. + const textStart = await firstCode.evaluate(element => Number.parseFloat(getComputedStyle(element).paddingLeft)) + await page.mouse.move(firstLine.x + textStart + 1, firstLine.y + firstLine.height / 2) await page.mouse.down() await page.mouse.move(lastLine.x + 120, lastLine.y + lastLine.height / 2, { steps: 5 }) await page.mouse.up() @@ -1169,6 +1172,78 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre expect(tripwire.warnings).toEqual([]) }) +it.skipIf(MODE === 'record')('opens a comment from the hover plus in one press without covering the line number or the code', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-hover-plus') + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await writeFile(join(project, 'README.md'), `changed\n${Array.from({ length: 8 }, (_, line) => `line ${line + 2}\n`).join('')}`) + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.locator('[data-forge-header]').click() + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更/ }).click() + const view = page.locator('[data-forge-changes="local"]') + const file = view.locator('[data-forge-diff-file="README.md"]') + const number = (name: string) => file.getByRole('button', { name: `选择第 ${name} 行`, exact: true }) + const plus = (name: string) => file.locator(`[data-review-add="${name}"]`) + const code = (line: number) => file.locator(`[data-review-code][data-new-line="${line}"]`) + const draggable = async (from: string, to: string) => { + const start = await plus(from).boundingBox(), end = await number(to).boundingBox() + if (start === null || end === null) throw new Error('The plus and the line number must be rendered') + const x = start.x + start.width / 2 + return { start: { x, y: start.y + start.height / 2 }, end: { x, y: end.y + end.height / 2 } } + } + await number('R3').waitFor() + + // The plus appears only on the hovered side of the hovered row, for the code as for the line number. + expect(await plus('R3').isVisible()).toBe(false) + await code(3).hover() + expect(await plus('R3').isVisible()).toBe(true) + expect(await plus('R4').isVisible()).toBe(false) + expect(await plus('L1').isVisible()).toBe(false) + await number('L1').hover() + expect(await plus('L1').isVisible()).toBe(true) + expect(await plus('R3').isVisible()).toBe(false) + + // Playwright refuses a click on a covered point, so these prove the hovered number and first character stay reachable. + await number('R3').hover() + await number('R3').click() + expect(await number('R3').getAttribute('aria-pressed')).toBe('true') + const first = await code(5).boundingBox() + const textStart = await code(5).evaluate(element => Number.parseFloat(getComputedStyle(element).paddingLeft)) + if (first === null) throw new Error('Review text must be visible') + await page.mouse.move(first.x + textStart + 1, first.y + first.height / 2) + await page.mouse.down() + await page.mouse.move(first.x + textStart + 30, first.y + first.height / 2, { steps: 3 }) + await page.mouse.up() + expect(await page.evaluate(() => document.getSelection()?.toString())).toMatch(/^li/) + await page.evaluate(() => { document.getSelection()?.removeAllRanges() }) + + // One press selects, a drag extends within the side, and the release opens the editor. + await code(6).hover() + const { start, end } = await draggable('R6', 'R8') + await page.mouse.move(start.x, start.y) + await page.mouse.down() + await page.mouse.move(end.x, end.y, { steps: 5 }) + expect(await number('R8').getAttribute('aria-pressed')).toBe('true') + expect(await file.getByRole('textbox').count()).toBe(0) + await page.mouse.up() + await file.getByRole('region', { name: '评论 R6–8', exact: true }).waitFor() + expect(await plus('R6').count()).toBe(0) + await file.getByRole('button', { name: '取消', exact: true }).click() + await file.getByRole('textbox').waitFor({ state: 'detached' }) + + // The single-column layout keeps the same gesture on the line's own side. + await view.getByRole('button', { name: '左右对比', exact: true }).click() + await file.locator('[data-diff-line]').first().waitFor() + await code(4).hover() + expect(await plus('R4').isVisible()).toBe(true) + await number('R4').click() + expect(await number('R4').getAttribute('aria-pressed')).toBe('true') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record')('keeps tall branch and changed-file menus aligned with their triggers', async () => { const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-tall-menus') await Promise.all(Array.from({ length: 45 }, (_, index) => diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 1c664c6e1b..d2d5416186 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 058035fcf2a43fd0 - zh: 92d4f4f3e68d0fc5 + en: 36241c0198e0ad15 + zh: 289df8b50bf1f445 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 80ba18c47e570e5e zh: 2f0fce0affa48f59 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 8056d0fe39..b1254fbab6 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -45,7 +45,7 @@ The diff follows the file tree’s depth-first order: directories before files, `gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). -All files opens a searchable directory tree. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. +All files opens a searchable directory tree. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. ----- diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 8f189c0d18..6bbf65496d 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -45,7 +45,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后 `gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 -“全部文件”打开可搜索的目录树;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”才打开纯文本评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 +“全部文件”打开可搜索的目录树;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 ----- diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index c1660b854a..69e2dea94a 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -459,6 +459,11 @@ padding: 0 16px 0 6px; } +/* Side-by-side cells have no sign column, so commentable code keeps one as an empty lane for the comment plus. */ +.code[data-split][data-review] .text { + padding-left: calc(1.4em + 6px); +} + .add { --forge-diff-gutter: var(--dsw-alias-file-diff-added-gutter); --forge-diff-marker: var(--dsw-alias-file-diff-added-marker); diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index a3c8d9a2ba..ec50d151aa 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -151,7 +151,8 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme const [selection, setSelection] = useState(null) const root = useRef(null) const origin = useRef<{ side: 'L' | 'R'; line: number } | null>(null) - const dragging = useRef<'gutter' | 'text' | null>(null) + // `add` is a gutter drag that began on a row's hover plus and opens the editor when the pointer is released. + const dragging = useRef<'gutter' | 'add' | 'text' | null>(null) const [selecting, setSelecting] = useState(false) const [editing, setEditing] = useState(false) const [nativeSide, setNativeSide] = useState<'L' | 'R' | null>(null) @@ -190,10 +191,12 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme }, [nativeSide]) useEffect(() => { if (!selecting) return - const finish = (): void => { + const finish = (event: PointerEvent): void => { if (dragging.current === 'text') nativeSelection() + const compose = dragging.current === 'add' && event.type === 'pointerup' dragging.current = null setSelecting(false) + if (compose) setEditing(true) } window.addEventListener('pointerup', finish) window.addEventListener('pointercancel', finish) @@ -215,14 +218,19 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } const selected = (side: 'L' | 'R', line: number | null): boolean => line !== null && selection !== null && selection.comment.side === side && line >= selection.comment.startLine && line <= selection.comment.endLine - const number = (side: 'L' | 'R', line: number | null): ReactNode => { + // `hover` adds the mouse shortcut: a plus shown while a hovering pointer is over this side of the row. + const number = (side: 'L' | 'R', line: number | null, hover: boolean): ReactNode => { if (line === null || review === undefined || !file.hunks.some((hunk) => { const start = side === 'L' ? hunk.oldStart : hunk.newStart return line >= start && line < start + (side === 'L' ? hunk.oldLines : hunk.newLines) })) { return {line ?? ''} } - return + const plus = !selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line + return { + if ((dragging.current === 'gutter' || dragging.current === 'add') && origin.current?.side === side && event.buttons === 1) choose(side, line, true) + }}> - {!selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line && ( + {plus && ( )} + {/* Keyboard and assistive technology reach the same editor through the line button and the labeled plus above. */} + {hover && !plus && !editing && ( + + )} } const cell = (value: SplitCell | null, spans: SideSpans, side: 'L' | 'R'): ReactNode => value === null ? : - {number(side, value.no)} + {number(side, value.no, true)} const rows: ReactNode[] = [] let inserted = false @@ -301,7 +323,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } else { for (const [index, row] of segment.rows.entries()) { rows.push(
- {singleSide !== 'R' && number('L', row.old)}{singleSide !== 'L' && number('R', row.new)}{SIGN[row.kind]} + {singleSide !== 'R' && number('L', row.old, row.kind === 'del')}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del')}{SIGN[row.kind]}
) if (!inserted && endsAt(row.old, row.new)) { @@ -317,6 +339,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } if (!inserted) rows.push(editor) return
{ if (event.button !== 0 || !(event.target instanceof Node)) return diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css index b86332cfbf..168caafc17 100644 --- a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -314,6 +314,19 @@ color: var(--dsw-alias-state-error-primary); } +/* Checks outcome filling the glyph's head branch end. */ +.checksMark[data-checks='failure'] { + fill: var(--dsw-alias-state-error-primary); +} + +.checksMark[data-checks='pending'] { + fill: var(--dsw-alias-state-warn-primary); +} + +.checksMark[data-checks='success'] { + fill: var(--dsw-alias-state-success-primary); +} + .chevron { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css index 16b7d752fd..9df0d316c3 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css @@ -26,4 +26,8 @@ @media (hover: hover) { .line:hover { color: var(--dsw-alias-state-business-primary); } } @media (pointer: coarse) { .input { font-size: 16px; } .line { min-height: 28px; } } -.add { position: absolute; inset-inline-start: 2px; top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); cursor: pointer; user-select: none; } +/* The plus starts just inside the number's padding and covers the sign column, or the empty lane side-by-side cells keep instead, so the line number and the code stay clickable. */ +.add, .hoverAdd { position: absolute; inset-inline-start: calc(100% - 4px); top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); cursor: pointer; user-select: none; } +/* The mouse shortcut appears only for the hovered side of a row: a split cell or a unified line. */ +.hoverAdd { display: none; } +@media (hover: hover) { [data-diff-side]:hover .hoverAdd, [data-diff-line]:hover .hoverAdd { display: inline-flex; } } diff --git a/packages/experimental/client-ui-forge/src/client/SessionList.module.css b/packages/experimental/client-ui-forge/src/client/SessionList.module.css index 6619640116..c5c9b0b6d3 100644 --- a/packages/experimental/client-ui-forge/src/client/SessionList.module.css +++ b/packages/experimental/client-ui-forge/src/client/SessionList.module.css @@ -1,6 +1,5 @@ /* One icon in the Session row's resting seat, sized to the row's 20px line. */ .icon { - position: relative; display: inline-flex; flex: none; align-items: center; @@ -9,30 +8,6 @@ color: var(--dsw-alias-label-tertiary); } -/* Checks outcome at the glyph's lower right, ringed in the sidebar fill so it parts from the strokes. */ -.dot { - position: absolute; - right: -2px; - bottom: 3px; - width: 6px; - height: 6px; - border-radius: 50%; - corner-shape: round; - box-shadow: 0 0 0 1.5px var(--dsw-specific-sidebar-fill); -} - -.dot[data-checks='failure'] { - background: var(--dsw-alias-state-error-primary); -} - -.dot[data-checks='pending'] { - background: var(--dsw-alias-state-warn-primary); -} - -.dot[data-checks='success'] { - background: var(--dsw-alias-state-success-primary); -} - /* Hover card section: the card keeps one dark surface in both themes, so it * uses the hover card label and state tokens. */ .hover { diff --git a/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx b/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx index 1072148a89..744846afa3 100644 --- a/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx +++ b/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx @@ -2,7 +2,7 @@ * Session row icons after the time label: a branch glyph while the Session * works in the worktree the panel created for it, and the state glyph of its * most urgent change request. Glyphs of open change requests stay in the - * row's subdued color with a dot for their checks; merged and closed ones keep + * row's subdued color with their checks filling the head branch end; merged and closed ones keep * their state color. */ @@ -62,6 +62,7 @@ export type SessionRowForgeProps = export function SessionRowForge({ sessionId, useSessions, useStates, t }: SessionRowForgeProps): ReactNode { const facts = useSessionForgeFacts(useSessions, useStates, sessionId) const top = facts.changeRequests[0] + const checks = top === undefined ? 'none' : rowChecks(top) return ( <> {facts.worktree !== null && ( @@ -76,8 +77,8 @@ export function SessionRowForge({ sessionId, useSessions, useStates, t }: Sessio aria-label={t('change.stateAria', { state: stateLine(top, t) })} data-forge-row-change={top.tone} > - - {rowChecks(top) !== 'none' && } + )} diff --git a/packages/experimental/client-ui-forge/src/client/glyphs.tsx b/packages/experimental/client-ui-forge/src/client/glyphs.tsx index 439b1da1e6..abe32e6ec3 100644 --- a/packages/experimental/client-ui-forge/src/client/glyphs.tsx +++ b/packages/experimental/client-ui-forge/src/client/glyphs.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import clsx from 'clsx' import { IconCheckCircleFillRegular, IconCloseCircleFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { CheckState } from '@deepseek-ai/dsh-experimental-forge/types' +import type { CheckState, ChecksRollup } from '@deepseek-ai/dsh-experimental-forge/types' import type { ChangeRequestTone } from './status.ts' import css from './ForgeTab.module.css' @@ -65,6 +65,16 @@ const SHAPES: Record = { ), } +/** Center of each shape's head branch end, which a checks mark replaces. */ +const HEAD: Record = { + open: [12, 12.5], + unknown: [12, 12.5], + blocked: [12, 12.5], + draft: [12, 12.5], + merged: [11.5, 8], + closed: [12, 12.5], +} + /** * Draw a change request in its lifecycle shape: open, open with a cross for * one that needs its author, draft, merged, closed, or the open shape for an @@ -73,13 +83,15 @@ const SHAPES: Record = { * @param props.tinted - whether the state picks the color; otherwise the glyph uses the current text color. * @param props.size - width and height in pixels, 15 by default. * @param props.label - accessible name; omit it for a decorative glyph. + * @param props.checks - checks outcome drawn as a filled head branch end in its state color; omit it for the plain end. * @returns the SVG glyph. */ -export function ChangeRequestGlyph({ tone, tinted = true, size = 15, label }: { +export function ChangeRequestGlyph({ tone, tinted = true, size = 15, label, checks }: { readonly tone: ChangeRequestTone readonly tinted?: boolean | undefined readonly size?: number | undefined readonly label?: string | undefined + readonly checks?: Exclude | undefined }): ReactNode { return ( {SHAPES[tone]} + {/* Concentric with the stroked end and larger than its outer edge, so it covers that end completely. */} + {checks !== undefined && } ) } diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index 4f4f0e1efd..eb4c287f8e 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -133,6 +133,44 @@ it('keeps shift text selection and allows ordinary text selection when review ac m.rerender() m.select('R', 3, 4) expect(m.queryByRole('button', { name: 'Add comment' })).toBeNull() + expect(m.container.querySelector('[data-review-add]')).toBeNull() +}) + +it('opens the editor from a hover plus in one press, extending the range on its side while dragging', () => { + const m = mount() + const plus = (name: string): HTMLElement => { + const element = m.container.querySelector(`[data-review-add="${name}"]`) + if (element === null) throw new Error(`missing hover plus ${name}`) + return element + } + expect(plus('L3').getAttribute('aria-hidden')).toBe('true') + fireEvent.pointerDown(plus('R3'), { button: 2 }) + expect(m.line('R3').getAttribute('aria-pressed')).toBe('false') + fireEvent.pointerDown(plus('R3'), { button: 0 }) + fireEvent.pointerOver(m.line('L4'), { buttons: 1 }) + expect(m.line('L4').getAttribute('aria-pressed')).toBe('false') + fireEvent.pointerOver(plus('R4'), { buttons: 1 }) + expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on R3–4' })).toBeTruthy() + expect(m.container.querySelector('[data-review-add]')).toBeNull() + fireEvent.click(m.getByRole('button', { name: 'Cancel' })) + fireEvent.pointerDown(plus('L2'), { button: 0 }) + fireEvent.pointerCancel(window) + expect(m.queryByRole('textbox')).toBeNull() + expect(m.line('L2').getAttribute('aria-pressed')).toBe('true') + expect(m.container.querySelector('[data-review-add="L2"]')).toBeNull() + fireEvent.click(m.getByRole('button', { name: 'Add comment' })) + expect(m.getByRole('region', { name: 'Comment on L2' })).toBeTruthy() +}) + +it('offers one hover plus per unified row, on the side its line belongs to', () => { + const m = mount({ split: false }) + expect(Array.from(m.container.querySelectorAll('[data-review-add]'), element => element.getAttribute('data-review-add'))) + .toEqual(['R2', 'L3', 'L4', 'R3', 'R4', 'R5']) + fireEvent.pointerDown(m.container.querySelector('[data-review-add="L3"]')!, { button: 0 }) + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on L3' })).toBeTruthy() }) it('refuses native and gutter ranges spanning collapsed context between hunks', () => { diff --git a/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx b/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx index d03d64d264..4ca42dbb88 100644 --- a/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/session-list.client.spec.tsx @@ -108,19 +108,19 @@ describe('SessionRowForge', () => { expect(screen.getByRole('img', { name: en['list.worktree'] }).getAttribute('data-forge-row-worktree')).toBe('') const change = screen.getByRole('img', { name: 'Change request state: Open · Checks running' }) expect(change.getAttribute('data-forge-row-change')).toBe('open') - expect(change.querySelector(`.${css.dot}`)?.getAttribute('data-checks')).toBe('pending') + expect(change.querySelector('[data-checks]')?.getAttribute('data-checks')).toBe('pending') }) it('names only the state of a finished or unpolled change request, without a checks dot', () => { const merged = renderRow({ forgeChangeRequests: [binding(1)] }, statesOf(state(1, { state: 'merged', checksRollup: 'failure' }))) const glyph = screen.getByRole('img', { name: 'Change request state: Merged' }) expect(glyph.getAttribute('data-forge-row-change')).toBe('merged') - expect(glyph.querySelector(`.${css.dot}`)).toBeNull() + expect(glyph.querySelector('[data-checks]')).toBeNull() expect(screen.queryByRole('img', { name: en['list.worktree'] })).toBeNull() merged.view.unmount() renderRow({ forgeChangeRequests: [binding(2)] }) - expect(screen.getByRole('img', { name: 'Change request state: Unknown' }).querySelector(`.${css.dot}`)).toBeNull() + expect(screen.getByRole('img', { name: 'Change request state: Unknown' }).querySelector('[data-checks]')).toBeNull() }) it('follows the listed values and the polled states as they change', () => { From a012eef69a3a66f1a865e6ceb19ad418c14509dd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 5 Nov 2026 16:27:33 +0800 Subject: [PATCH 2/6] fix(forge): keep comment pluses reachable in unified rows and refuse stale presses In unified rows that show both line numbers, the left plus now moves past the right number into the sign column, where the right plus already sits. It paints above that number's padding, the sticky file header stays above it, and a row that shows the labeled plus offers no second hover plus. The labeled plus of a left context line is reachable again, and a deleted line's plus no longer jumps one column. A drag that starts on a hover plus extends through every row with a line on its starting side, wherever the pointer crosses the row. A press whose range is refused, such as Shift across hunks, keeps the previous selection and no longer opens the editor for it on release. The README describes the checks mark as the filled head branch end. Refs #7937 --- apps/web/tests/forge-panel.e2e.ts | 31 ++++++++++++ .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangesTab.module.css | 3 +- .../client-ui-forge/src/client/DiffFile.tsx | 34 ++++++++----- .../src/client/ReviewComposer.module.css | 5 +- .../client-ui-forge/src/client/glyphs.tsx | 2 +- .../tests/diff-review.client.spec.tsx | 49 ++++++++++++++++++- 9 files changed, 112 insertions(+), 20 deletions(-) diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 05c8e6bc10..5ba38a556e 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -1174,9 +1174,14 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre it.skipIf(MODE === 'record')('opens a comment from the hover plus in one press without covering the line number or the code', async () => { const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-hover-plus') + // notes.txt changes into context, a deletion followed by context, and an addition, so unified rows show both numbers. + await writeFile(join(project, 'notes.txt'), ['keep 1', 'keep 2', 'drop 3', 'keep 4', 'keep 5', 'keep 6'].map(line => `${line}\n`).join('')) + await git(project, 'add', 'notes.txt') + await git(project, 'commit', '--quiet', '-m', 'notes') await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') await writeFile(join(project, 'README.md'), `changed\n${Array.from({ length: 8 }, (_, line) => `line ${line + 2}\n`).join('')}`) + await writeFile(join(project, 'notes.txt'), ['keep 1', 'keep 2', 'keep 4', 'keep 5', 'added', 'keep 6'].map(line => `${line}\n`).join('')) const { settled } = await sendPrompt(page, scaffold) await settled await page.locator('[data-forge-header]').click() @@ -1240,6 +1245,32 @@ it.skipIf(MODE === 'record')('opens a comment from the hover plus in one press w expect(await plus('R4').isVisible()).toBe(true) await number('R4').click() expect(await number('R4').getAttribute('aria-pressed')).toBe('true') + + // With both numbers shown, a deleted line's plus sits where a context line's does: in the sign column. + const notes = view.locator('[data-forge-diff-file="notes.txt"]') + const notesPlus = (name: string) => notes.locator(`[data-review-add="${name}"]`) + const notesCode = (side: 'old' | 'new', line: number) => notes.locator(`[data-review-code][data-${side}-line="${line}"]`) + await notesCode('new', 3).hover() + const contextPlus = await notesPlus('R3').boundingBox() + await notesCode('old', 3).hover() + const deletedPlus = await notesPlus('L3').boundingBox() + const nextRow = await notesCode('old', 4).boundingBox() + if (contextPlus === null || deletedPlus === null || nextRow === null) throw new Error('The pluses and the next row must be rendered') + expect(deletedPlus.x).toBe(contextPlus.x) + // A drag from the deleted line's plus extends through the context row below it on the old side. + const x = deletedPlus.x + deletedPlus.width / 2 + await page.mouse.move(x, deletedPlus.y + deletedPlus.height / 2) + await page.mouse.down() + await page.mouse.move(x, nextRow.y + nextRow.height / 2, { steps: 5 }) + await page.mouse.up() + await notes.getByRole('region', { name: '评论 L3–4', exact: true }).waitFor() + await notes.getByRole('button', { name: '取消', exact: true }).click() + await notes.getByRole('textbox').waitFor({ state: 'detached' }) + // The labeled plus of a left context line stays reachable; Playwright refuses a covered target. + await notes.getByRole('button', { name: '选择第 L2 行', exact: true }).click() + expect((await notes.getByRole('button', { name: '添加评论', exact: true }).boundingBox())?.x).toBe(contextPlus.x) + await notes.getByRole('button', { name: '添加评论', exact: true }).click() + await notes.getByRole('region', { name: '评论 L2', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index d2d5416186..5ae68e348c 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 36241c0198e0ad15 - zh: 289df8b50bf1f445 + en: 3f8b0e49e91d7255 + zh: 4037fa8ada444bc2 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 80ba18c47e570e5e zh: 2f0fce0affa48f59 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index b1254fbab6..2c5a473c42 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -34,7 +34,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph. - **Code review and changes** — on the right-sidebar start page, opens the same change request tab as the Session header button. - **Header button** — appears when the Session has bound change requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open change request with failed checks, requested changes, or conflicts. -- **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color with a dot for running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll. +- **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color, with its head branch end filled in the color of running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll. - **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. Cancelled checks have a separate count; a collapsed card names cancellations when no listed check failed. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. - **Local changes card** — whenever the `branch` scope changes files, independently of bound change requests, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, which is always the default base below, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered when the code host repository is recognized and no open change request (including drafts) has the checked-out branch as its head, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 6bbf65496d..86cd4a415f 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" - **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。 - **代码审查和变更**——右侧栏起始页中的入口,与会话标题栏按钮打开同一个变更请求标签页。 - **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。 -- **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查进行中、已通过或失败;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 +- **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,head 分支端点填充为检查进行中、已通过或失败对应的颜色;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 - **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。已取消的检查单独计数;列出的检查没有失败项时,折叠卡片显示取消数量。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 - **本地变更卡片**——`branch` 范围有文件变更时始终显示,不受已关联变更请求影响。变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`,始终是下文的默认基准)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库、且没有以当前检出分支为源分支的打开中变更请求(含草稿)时,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 69e2dea94a..47193f0887 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -307,10 +307,11 @@ border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +/* Above the comment plus (z-index 1) of rows scrolled beneath it. */ .fileHeader { position: sticky; top: 0; - z-index: 1; + z-index: 2; display: flex; align-items: center; gap: 6px; diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index ec50d151aa..eb7e21e6b0 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -208,13 +208,22 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme const prepared = useMemo(() => segmentsOf(file, lines, open).map((segment): Prepared => segment.kind === 'gap' ? segment : { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter]) - const choose = (side: 'L' | 'R', line: number, extend: boolean): void => { - if (review === undefined || editing) return + // Returns whether the range was accepted; a range across hunks or outside them keeps the previous selection. + const choose = (side: 'L' | 'R', line: number, extend: boolean): boolean => { + if (review === undefined || editing) return false const from = extend && origin.current?.side === side ? origin.current.line : line const comment = selectReviewLines(file, side, from, line) - if (comment === undefined) return + if (comment === undefined) return false if (!extend || origin.current?.side !== side) origin.current = { side, line } setSelection({ source: review.source, comment }) + return true + } + // A drag that began on a hover plus extends through every row with a line on its starting side, wherever the pointer crosses that row. + const extendAdd = (buttons: number, old: number | null, next: number | null): void => { + const from = origin.current + if (dragging.current !== 'add' || buttons !== 1 || from === null) return + const line = from.side === 'L' ? old : next + if (line !== null) choose(from.side, line, true) } const selected = (side: 'L' | 'R', line: number | null): boolean => line !== null && selection !== null && selection.comment.side === side && line >= selection.comment.startLine && line <= selection.comment.endLine @@ -227,10 +236,8 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme return {line ?? ''} } const plus = !selecting && !editing && selection?.comment.side === side && selection.comment.endLine === line - return { - if ((dragging.current === 'gutter' || dragging.current === 'add') && origin.current?.side === side && event.buttons === 1) choose(side, line, true) - }}> + return { if (dragging.current === 'gutter' && origin.current?.side === side && event.buttons === 1) choose(side, line, true) }}> ) } else if (columns) { for (const [index, pair] of splitRows(segment.rows).entries()) { - rows.push(
+ rows.push(
{ extendAdd(event.buttons, pair.left?.no ?? null, pair.right?.no ?? null) }}> {cell(pair.left, segment.old, 'L')}{cell(pair.right, segment.new, 'R')}
) if (!inserted && endsAt(pair.left?.no ?? null, pair.right?.no ?? null)) { @@ -322,8 +331,11 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme } } else { for (const [index, row] of segment.rows.entries()) { - rows.push(
- {singleSide !== 'R' && number('L', row.old, row.kind === 'del')}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del')}{SIGN[row.kind]} + // Both numbers place their plus in the sign column, so a row showing the labeled plus offers no hover plus. + const labeled = !selecting && !editing && endsAt(row.old, row.new) + rows.push(
{ extendAdd(event.buttons, row.old, row.new) }}> + {singleSide !== 'R' && number('L', row.old, row.kind === 'del' && !labeled)}{singleSide !== 'L' && number('R', row.new, row.kind !== 'del' && !labeled)}{SIGN[row.kind]}
) if (!inserted && endsAt(row.old, row.new)) { diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css index 9df0d316c3..5ce683c482 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.module.css @@ -27,7 +27,10 @@ @media (pointer: coarse) { .input { font-size: 16px; } .line { min-height: 28px; } } /* The plus starts just inside the number's padding and covers the sign column, or the empty lane side-by-side cells keep instead, so the line number and the code stay clickable. */ -.add, .hoverAdd { position: absolute; inset-inline-start: calc(100% - 4px); top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); cursor: pointer; user-select: none; } +.add, .hoverAdd { position: absolute; z-index: 1; inset-inline-start: calc(100% - 4px); top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--dsw-radius-xs); background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); font: inherit; cursor: pointer; user-select: none; } +/* Unified rows that show both numbers move the left plus past the right number too; it paints over that number's padding. The plus inherits the code font because the number width is measured in `ch`. */ +[data-review]:not([data-split]):not([data-single-side]) [data-review-side="L"] .add, +[data-review]:not([data-split]):not([data-single-side]) [data-review-side="L"] .hoverAdd { inset-inline-start: calc(100% + var(--forge-number-width, 3.5em) - 4px); } /* The mouse shortcut appears only for the hovered side of a row: a split cell or a unified line. */ .hoverAdd { display: none; } @media (hover: hover) { [data-diff-side]:hover .hoverAdd, [data-diff-line]:hover .hoverAdd { display: inline-flex; } } diff --git a/packages/experimental/client-ui-forge/src/client/glyphs.tsx b/packages/experimental/client-ui-forge/src/client/glyphs.tsx index abe32e6ec3..fb9fefca87 100644 --- a/packages/experimental/client-ui-forge/src/client/glyphs.tsx +++ b/packages/experimental/client-ui-forge/src/client/glyphs.tsx @@ -65,7 +65,7 @@ const SHAPES: Record = { ), } -/** Center of each shape's head branch end, which a checks mark replaces. */ +/** Center of each shape's head branch end, which a checks mark covers. */ const HEAD: Record = { open: [12, 12.5], unknown: [12, 12.5], diff --git a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx index eb4c287f8e..ba321c48fc 100644 --- a/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/diff-review.client.spec.tsx @@ -147,10 +147,15 @@ it('opens the editor from a hover plus in one press, extending the range on its fireEvent.pointerDown(plus('R3'), { button: 2 }) expect(m.line('R3').getAttribute('aria-pressed')).toBe('false') fireEvent.pointerDown(plus('R3'), { button: 0 }) + // Crossing a row extends on the starting side, even over the other column. fireEvent.pointerOver(m.line('L4'), { buttons: 1 }) expect(m.line('L4').getAttribute('aria-pressed')).toBe('false') - fireEvent.pointerOver(plus('R4'), { buttons: 1 }) expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerOver(plus('R4'), { buttons: 0 }) + fireEvent.pointerOver(plus('R5'), { buttons: 1 }) + expect(m.line('R5').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerOver(plus('R4'), { buttons: 1 }) + expect(m.line('R5').getAttribute('aria-pressed')).toBe('false') fireEvent.pointerUp(window) expect(m.getByRole('region', { name: 'Comment on R3–4' })).toBeTruthy() expect(m.container.querySelector('[data-review-add]')).toBeNull() @@ -164,6 +169,40 @@ it('opens the editor from a hover plus in one press, extending the range on its expect(m.getByRole('region', { name: 'Comment on L2' })).toBeTruthy() }) +it('extends a unified hover-plus drag through rows with a line on its side and keeps one plus per row', () => { + // Old 1–4: keep 1, drop 2, keep 3, keep 4; new 1–4: keep 1, keep 3, added, keep 4. + const mixed: ForgeDiffFile = { ...file, additions: 1, deletions: 1, + hunks: [{ oldStart: 1, oldLines: 4, newStart: 1, newLines: 4, lines: [' keep 1', '-drop 2', ' keep 3', '+added', ' keep 4'] }] } + const m = mount({ split: false, file: mixed }) + fireEvent.pointerDown(m.container.querySelector('[data-review-add="L2"]')!, { button: 0 }) + // Over the context row the pointer meets its new number, yet the drag extends on the old side. + fireEvent.pointerOver(m.line('R2'), { buttons: 1 }) + expect(m.line('L3').getAttribute('aria-pressed')).toBe('true') + // The added row has no old line, so the range stays. + fireEvent.pointerOver(m.line('R3'), { buttons: 1 }) + expect(m.line('L3').getAttribute('aria-pressed')).toBe('true') + fireEvent.pointerOver(m.line('R4'), { buttons: 1 }) + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on L2–4' })).toBeTruthy() + fireEvent.click(m.getByRole('button', { name: 'Cancel' })) + // A gutter drag still extends only over numbers of its own side. + fireEvent.pointerDown(m.line('L1'), { button: 0 }) + fireEvent.pointerOver(m.line('R2'), { buttons: 1 }) + expect(m.line('L3').getAttribute('aria-pressed')).toBe('false') + fireEvent.pointerUp(window) + // The row that shows the labeled plus offers no second hover plus at the same place. + fireEvent.click(m.line('L3')) + expect(m.getByRole('button', { name: 'Add comment' })).toBeTruthy() + expect(m.container.querySelector('[data-review-add="R2"]')).toBeNull() + expect(m.container.querySelector('[data-review-add="R4"]')).not.toBeNull() + // Side by side, the deleted line's row has no new line, so a drag on the new side passes over it. + m.rerender() + fireEvent.pointerDown(m.container.querySelector('[data-review-add="R1"]')!, { button: 0 }) + fireEvent.pointerOver(m.line('L2'), { buttons: 1 }) + fireEvent.pointerUp(window) + expect(m.getByRole('region', { name: 'Comment on R1' })).toBeTruthy() +}) + it('offers one hover plus per unified row, on the side its line belongs to', () => { const m = mount({ split: false }) expect(Array.from(m.container.querySelectorAll('[data-review-add]'), element => element.getAttribute('data-review-add'))) @@ -173,7 +212,7 @@ it('offers one hover plus per unified row, on the side its line belongs to', () expect(m.getByRole('region', { name: 'Comment on L3' })).toBeTruthy() }) -it('refuses native and gutter ranges spanning collapsed context between hunks', () => { +it('refuses native, gutter, and hover-plus ranges spanning collapsed context between hunks', () => { const m = mount({ file: { ...file, hunks: [...file.hunks, { oldStart: 10, oldLines: 1, newStart: 10, newLines: 1, lines: ['-before', '+after'] }] } }) m.select('R', 4, 10) @@ -182,6 +221,12 @@ it('refuses native and gutter ranges spanning collapsed context between hunks', fireEvent.click(m.line('R10'), { shiftKey: true }) expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') expect(m.line('R10').getAttribute('aria-pressed')).toBe('false') + // A refused press keeps the previous selection, and its release opens no editor for it. + fireEvent.pointerDown(m.container.querySelector('[data-review-add="R10"]')!, { button: 0, shiftKey: true }) + fireEvent.pointerUp(window) + expect(m.queryByRole('textbox')).toBeNull() + expect(m.line('R4').getAttribute('aria-pressed')).toBe('true') + expect(m.line('R10').getAttribute('aria-pressed')).toBe('false') }) it('places left and right records in unified rows and retains outdated records for hidden hunks', () => { From 1c3080677369dc1d5422fd756ebd13f2a9189b30 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 5 Nov 2026 17:05:10 +0800 Subject: [PATCH 3/6] feat(worktree): allow five minutes per command by default Checking out a large repository on a slow filesystem can take longer than the previous 60-second deadline, which failed worktree creation with WORKTREE_TIMEOUT. The default timeoutMs is now 300000 for each Git and Node command; cancellation still stops the running command, and deployments can still set timeoutMs. Refs #7937 --- packages/experimental/worktree/README.i18n.yaml | 4 ++-- packages/experimental/worktree/README.md | 2 +- packages/experimental/worktree/README.zh.md | 2 +- packages/experimental/worktree/src/index.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/experimental/worktree/README.i18n.yaml b/packages/experimental/worktree/README.i18n.yaml index 44397cbabf..b45c962d0a 100644 --- a/packages/experimental/worktree/README.i18n.yaml +++ b/packages/experimental/worktree/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-worktree/use-this-package: - en: a31b883ab4fed407 - zh: b344c60b2bcb006b + en: c503ec46992ee6ab + zh: 6bea9fcb765c7286 /deepseek-ai-dsh-experimental-worktree/understand-the-implementation: en: 6971a7ca9073bef1 zh: b977c28a75c7f963 diff --git a/packages/experimental/worktree/README.md b/packages/experimental/worktree/README.md index 550aa2c5a3..f078236c9d 100644 --- a/packages/experimental/worktree/README.md +++ b/packages/experimental/worktree/README.md @@ -36,7 +36,7 @@ Mount the service alongside `workingDirectory`, `fs`, `subprocess`, `sandbox`, a `ctx.worktrees.restore(agent, { path, branch, baseCommit, repositoryRoot }, signal?, onPhase?)` checks a removed worktree out again at its recorded directory. It validates the recorded repository root, full commit object name, branch, and canonical target inside the configured pool before mutation. It removes only the missing target's unlocked registration with `git worktree remove`; other worktrees and locked registrations remain unchanged. It then checks out the branch when it still exists, or creates the branch again at `baseCommit` when it does not, and reports which happened as `branchRecreated`. An existing directory is an error. The calling Session's working directory does not change, and changes that were never committed are not recovered. Checkout disables filters as creation does. `onPhase` receives `prepare` before validation, target-registration cleanup, and branch creation, and `checkout` before the files are written; it must not throw. -The default checkout directory is `/.agents/worktrees/`. The returned `repositoryRoot` is the top-level directory of the caller’s current checkout; when it is a linked checkout, the pool is nested inside it. Omitted names join the `dsh-` prefix, the first eight hexadecimal digits of the UUID in the Session id, a hyphen, and four random hexadecimal digits, such as `dsh-6b1f02c4-9a3e`; a Session id without a UUID gets eight random hexadecimal digits after the prefix. `generatedWorktreeName` exports the same rule for callers that name a worktree before creating it. The service writes a `.gitignore` containing `*` and a trailing newline only when its allocation creates the pool; existing files and ignore rules are preserved. Relative `directory`, generated `namePrefix`, executable choices, and process limits are configurable in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-worktree). +The default checkout directory is `/.agents/worktrees/`. The returned `repositoryRoot` is the top-level directory of the caller’s current checkout; when it is a linked checkout, the pool is nested inside it. Omitted names join the `dsh-` prefix, the first eight hexadecimal digits of the UUID in the Session id, a hyphen, and four random hexadecimal digits, such as `dsh-6b1f02c4-9a3e`; a Session id without a UUID gets eight random hexadecimal digits after the prefix. `generatedWorktreeName` exports the same rule for callers that name a worktree before creating it. The service writes a `.gitignore` containing `*` and a trailing newline only when its allocation creates the pool; existing files and ignore rules are preserved. Each Git and Node command runs under its own `timeoutMs` deadline, 5 minutes by default, so checking out a large repository on a slow filesystem can finish; cancellation still stops the running command. Relative `directory`, generated `namePrefix`, executable choices, and process limits are configurable in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-worktree). ----- diff --git a/packages/experimental/worktree/README.zh.md b/packages/experimental/worktree/README.zh.md index 937b057975..205d00898f 100644 --- a/packages/experimental/worktree/README.zh.md +++ b/packages/experimental/worktree/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `ctx.worktrees.restore(agent, { path, branch, baseCommit, repositoryRoot }, signal?, onPhase?)` 在记录的目录重新检出被删除的 worktree。它在修改前校验记录中的仓库根目录、完整提交对象名、分支和配置池内的规范目标路径。它只通过 `git worktree remove` 移除缺失目标的未锁定登记;其他 worktree 与锁定登记保持不变。随后在分支仍存在时检出该分支,否则在 `baseCommit` 重新创建同名分支,并通过 `branchRecreated` 报告是哪种情况。目录已存在时会报错。调用方 Session 的工作目录不变,从未提交的修改无法恢复。检出与创建时一样禁用过滤器。`onPhase` 在检查目录、清理登记与创建分支之前收到 `prepare`,在写入文件之前收到 `checkout`;它不能抛出异常。 -默认检出目录是 `/.agents/worktrees/`。返回的 `repositoryRoot` 是调用方当前检出目录的顶层目录;如果它是链接检出目录,工作树父目录就嵌套在其中。省略名称时,名称由前缀 `dsh-`、Session id 中 UUID 的前 8 位十六进制数、一个连字符和 4 位随机十六进制数组成,例如 `dsh-6b1f02c4-9a3e`;Session id 不含 UUID 时,前缀后接 8 位随机十六进制数。`generatedWorktreeName` 导出同一规则,供在创建前确定名称的调用方使用。仅当服务的分配操作创建了父目录时,服务才会写入内容为 `*` 加换行符的 `.gitignore`;已有文件与忽略规则保持不变。相对路径 `directory`、生成名称使用的 `namePrefix`、可执行程序与进程限制可在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-worktree)中查阅。 +默认检出目录是 `/.agents/worktrees/`。返回的 `repositoryRoot` 是调用方当前检出目录的顶层目录;如果它是链接检出目录,工作树父目录就嵌套在其中。省略名称时,名称由前缀 `dsh-`、Session id 中 UUID 的前 8 位十六进制数、一个连字符和 4 位随机十六进制数组成,例如 `dsh-6b1f02c4-9a3e`;Session id 不含 UUID 时,前缀后接 8 位随机十六进制数。`generatedWorktreeName` 导出同一规则,供在创建前确定名称的调用方使用。仅当服务的分配操作创建了父目录时,服务才会写入内容为 `*` 加换行符的 `.gitignore`;已有文件与忽略规则保持不变。每条 Git 和 Node 命令各自受 `timeoutMs` 时限约束,默认 5 分钟,使大仓库在较慢的文件系统上也能完成检出;取消仍会终止正在运行的命令。相对路径 `directory`、生成名称使用的 `namePrefix`、可执行程序与进程限制可在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-worktree)中查阅。 ----- diff --git a/packages/experimental/worktree/src/index.ts b/packages/experimental/worktree/src/index.ts index 2529ae48e8..ff5e41948d 100644 --- a/packages/experimental/worktree/src/index.ts +++ b/packages/experimental/worktree/src/index.ts @@ -66,7 +66,7 @@ export class WorktreeService extends Service { namePrefix: z.string().default('dsh-'), gitCommand: z.string().default('git'), nodeCommand: z.string().default('node'), - timeoutMs: z.number().default(60_000), + timeoutMs: z.number().default(300_000), graceMs: z.number().default(3_000), maxOutputBytes: z.number().default(65_536), }) From d2c00f2554d22b2e7357ad5939316c23f5fed46f Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 5 Nov 2026 19:41:07 +0800 Subject: [PATCH 4/6] feat(llm-pi-ai): support the Gemini protocol for custom model APIs Add pi-ai's google-generative-ai protocol to hand-declared routes, read Gemini's native model listing during discovery, and offer it as "Gemini API" in the Custom model API form. Refs #7978 Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com> --- docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- .../ui-settings-models/README.i18n.yaml | 8 +- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/CustomProviderCard.tsx | 19 +++-- .../ui-settings-models/src/client/locales.ts | 32 +++++-- .../src/client/protocol-label.ts | 1 + .../tests/provider-form.client.spec.tsx | 9 +- packages/llm/llm-pi-ai/README.i18n.yaml | 8 +- packages/llm/llm-pi-ai/README.md | 7 +- packages/llm/llm-pi-ai/README.zh.md | 7 +- packages/llm/llm-pi-ai/src/discovery.ts | 73 +++++++++++++--- packages/llm/llm-pi-ai/src/provider.ts | 2 + .../llm/llm-pi-ai/tests/discovery.spec.ts | 48 ++++++++++- .../llm-pi-ai/tests/gemini-protocol.spec.ts | 83 +++++++++++++++++++ 17 files changed, 263 insertions(+), 50 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index b04aada43b..899572eab5 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -12,8 +12,8 @@ en: f6fb44a85dc5e4dd zh: 2b86ea4492c32ed7 /configure-models/add-a-custom-model-api: - en: 17867305a3e1b52a - zh: 876fedf1f6f563e4 + en: 1f55d7d94cbfbd80 + zh: 94e5a8bc7625d760 /configure-models/add-a-custom-model-api/discover-models: en: ec10ba53a00eddc6 zh: 857373a4e0fefee4 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index fe831bcaf9..90b70e0696 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -26,7 +26,7 @@ A nonempty key input has an eye button. Select **Show secret** to check what you ## Add a custom model API -Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in the active profile's `cordis.patch.yml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers. +Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers four: OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, and Gemini API, stored in the active profile's `cordis.patch.yml` as `openai-completions`, `openai-responses`, `anthropic-messages`, and `google-generative-ai`. A Gemini API base URL includes the API version, such as `https://gateway.example/v1beta`. A provider speaks one protocol, so a gateway that serves two needs two providers. ![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index bb518c42b9..c580fd2ab0 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -26,7 +26,7 @@ ## 添加自定义模型 API -对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在当前 profile 的 `cordis.patch.yml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。 +对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供四种:OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 和 Gemini API,在当前 profile 的 `cordis.patch.yml` 中分别存为 `openai-completions`、`openai-responses`、`anthropic-messages` 和 `google-generative-ai`。Gemini API 的基础 URL 包含 API 版本,例如 `https://gateway.example/v1beta`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。 ![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index b498c4ba84..6c1b573574 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -24,8 +24,8 @@ en: 03a9953b8aa0c97a zh: f3a5bcaa34193bcd /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: - en: c0b04b2b11af60d3 - zh: d195eec281e445cb + en: 7ef7686925d0e4e1 + zh: 92b9dba0a9dd08c1 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs: en: 589397a35f82244e zh: 342fbceee2c173a2 @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: 9779c98fff4c949a /deepseek-ai-dsh-client-ui-settings-models/known-limitations-and-deferred-work: - en: 480bf2ddd4b76779 - zh: dfda0c29be636f84 + en: 352101d81796eea8 + zh: dadae6c3032471ab /deepseek-ai-dsh-client-ui-settings-models/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 84c0e2d678..8da186a81c 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -69,7 +69,7 @@ Empty pi-ai capacity fields display `1M` for the context window and `128K` for m ### Adding and deleting providers -**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI-, Anthropic-, or Gemini-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, Gemini API — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`, and Gemini API shows `https://gateway.example/v1beta` because its base URL carries the API version. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs @@ -144,7 +144,7 @@ These limits define the editor's field coverage and the page's reach; they are c - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-API form writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. - **The catalog select lists route identifiers** — `moonshotai`, `zai`, and the other pi-ai catalog ids appear as they are, with no product names, aliases, or search. A searchable picker with the custom form as one pinned entry would let the mode switch go; it needs the directory to carry display names first. -- **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible, Anthropic Messages, and Gemini API endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic and Gemini use their native model-listing routes; every other protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 9fb8a65ef5..34fb41b61e 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -69,7 +69,7 @@ pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token ### 新增与删除提供商 -**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages、Gemini API——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`;Gemini API 显示 `https://gateway.example/v1beta`,因为其基础 URL 包含 API 版本。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 @@ -144,7 +144,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义模型 API 表单写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供商的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 - **目录选择框列出的是路由标识符**:`moonshotai`、`zai` 等 pi-ai catalog id 原样显示,没有产品名、别名或搜索。一个把自定义表单作为置顶项的可搜索选择器可以取代方式切换;前提是目录先携带显示名称。 -- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 +- **询问覆盖 OpenAI 兼容、Anthropic Messages 与 Gemini API 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 与 Gemini 则使用各自的原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 1a16603664..408df27dba 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -1,7 +1,6 @@ /** - * The card that declares a provider pi-ai does not ship — an OpenAI-compatible - * gateway, a self-hosted server, or a provider newer than the installed - * catalog. + * The card that declares a provider pi-ai does not ship — a gateway, a + * self-hosted server, or a provider newer than the installed catalog. * * This is a create, not an edit, which is why it is its own form rather than * the provider editor with extra fields: the route id is being *chosen* here, @@ -54,6 +53,16 @@ const NS = 'llm-pi-ai' */ const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ +/** + * Endpoint examples for protocols whose SDK expects a different base than an + * OpenAI-compatible `/v1`: Anthropic's SDK appends `/v1/messages`, and the + * Gemini protocol expects the API version in the base URL. + */ +const BASE_URL_PLACEHOLDERS: Readonly> = { + 'anthropic-messages': 'customAnthropicBaseUrlPlaceholder', + 'google-generative-ai': 'customGeminiBaseUrlPlaceholder', +} + function isHttpUrl(value: string): boolean { try { const protocol = new URL(value).protocol @@ -250,9 +259,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} type="text" value={baseURL} - placeholder={t(protocol === 'anthropic-messages' - ? 'customAnthropicBaseUrlPlaceholder' - : 'customBaseUrlPlaceholder')} + placeholder={t(BASE_URL_PLACEHOLDERS[protocol] ?? 'customBaseUrlPlaceholder')} aria-label={t('baseUrl')} aria-invalid={baseUrlInvalid} disabled={profileDisabled} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 65a21bdf66..f5e3e03934 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -20,7 +20,7 @@ export const en = { addCatalog: 'Third-party model provider', addCustom: 'Custom model API', addCatalogHint: 'Pick OpenAI, Anthropic, Kimi, or another provider from the built-in catalog and enter its API key.', - addCustomHint: 'Connect a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint by its base URL, protocol, and models.', + addCustomHint: 'Connect a relay, a self-hosted server, or any other OpenAI-, Anthropic-, or Gemini-compatible endpoint by its base URL, protocol, and models.', addCatalogExhausted: 'Every catalog provider is already configured.', addCustomUnavailable: 'No API protocol is available to declare.', provider: 'Provider', @@ -150,11 +150,13 @@ export const en = { protocolOpenAiCompletions: 'OpenAI Chat Completions', protocolOpenAiResponses: 'OpenAI Responses', protocolAnthropicMessages: 'Anthropic Messages', + protocolGemini: 'Gemini API', customNeedsBaseUrl: 'A custom provider needs a base URL.', customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.', customNeedsModels: 'A custom provider needs at least one model.', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: 'unresolvable settings path', create: 'Create provider', creating: 'Creating\u2026', @@ -193,7 +195,7 @@ export const zh: { [Key in keyof typeof en]: string } = { addCatalog: '第三方模型提供商', addCustom: '自定义模型 API', addCatalogHint: '从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。', - addCustomHint: '连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。', + addCustomHint: '连接中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口,需填写 API 地址、协议和模型。', addCatalogExhausted: '目录中的提供商都已添加。', addCustomUnavailable: '没有可用的 API 协议。', provider: '提供商', @@ -323,11 +325,13 @@ export const zh: { [Key in keyof typeof en]: string } = { protocolOpenAiCompletions: 'OpenAI Chat Completions', protocolOpenAiResponses: 'OpenAI Responses', protocolAnthropicMessages: 'Anthropic Messages', + protocolGemini: 'Gemini API', customNeedsBaseUrl: '自定义模型 API 需要填写 API 地址。', customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。', customNeedsModels: '自定义模型 API 至少需要一个模型。', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: '无法解析设置路径', create: '创建提供商', creating: '创建中\u2026', @@ -363,7 +367,7 @@ export const ja = { 'addCatalog': 'サードパーティのモデルプロバイダー', 'addCustom': 'カスタムモデルAPI', 'addCatalogHint': '組み込みカタログからOpenAI、Anthropic、Kimiなどのプロバイダーを選択し、APIキーを入力してください。', - 'addCustomHint': 'ベースURL、プロトコル、モデルを指定して、リレー、セルフホストサーバー、その他のOpenAI/Anthropic互換エンドポイントに接続します。', + 'addCustomHint': 'ベースURL、プロトコル、モデルを指定して、リレー、セルフホストサーバー、その他のOpenAI/Anthropic/Gemini互換エンドポイントに接続します。', 'addCatalogExhausted': 'カタログ内のすべてのプロバイダーが設定済みです。', 'addCustomUnavailable': '宣言できるAPIプロトコルがありません。', 'provider': 'プロバイダー', @@ -493,11 +497,13 @@ export const ja = { 'protocolOpenAiCompletions': 'OpenAI Chat Completions', 'protocolOpenAiResponses': 'OpenAI Responses', 'protocolAnthropicMessages': 'Anthropic Messages', + 'protocolGemini': 'Gemini API', 'customNeedsBaseUrl': 'カスタムプロバイダーにはベースURLが必要です。', 'customBaseUrlInvalid': '有効なHTTPまたはHTTPSのURLを入力してください。', 'customNeedsModels': 'カスタムプロバイダーには少なくとも1つのモデルが必要です。', 'customBaseUrlPlaceholder': 'https://gateway.example/v1', 'customAnthropicBaseUrlPlaceholder': 'https://gateway.example', + 'customGeminiBaseUrlPlaceholder': 'https://gateway.example/v1beta', 'settingsPathUnresolvable': '解決できない設定パス', 'create': 'プロバイダーを作成', 'creating': '作成中…', @@ -533,7 +539,7 @@ export const es = { addCatalog: 'Proveedor de modelos externo', addCustom: 'Modelo personalizado API', addCatalogHint: 'Elija OpenAI, Anthropic, Kimi u otro proveedor del catálogo integrado e ingrese su clave API.', - addCustomHint: 'Conecte un repetidor, un servidor autohospedado o cualquier otro punto final compatible con OpenAI o Anthropic mediante su URL base, protocolo y modelos.', + addCustomHint: 'Conecte un repetidor, un servidor autohospedado o cualquier otro punto final compatible con OpenAI, Anthropic o Gemini mediante su URL base, protocolo y modelos.', addCatalogExhausted: 'Cada proveedor de catálogo ya está configurado.', addCustomUnavailable: 'No hay ningún protocolo API disponible para declarar.', provider: 'Proveedor', @@ -663,11 +669,13 @@ export const es = { protocolOpenAiCompletions: 'Finalizaciones del chat OpenAI', protocolOpenAiResponses: 'Respuestas de OpenAI', protocolAnthropicMessages: 'Mensajes antrópicos', + protocolGemini: 'Gemini API', customNeedsBaseUrl: 'Un proveedor personalizado necesita una URL base.', customBaseUrlInvalid: 'Ingrese una URL HTTP o HTTPS válida.', customNeedsModels: 'Un proveedor personalizado necesita al menos un modelo.', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: 'ruta de configuración irresoluble', create: 'Crear proveedor', creating: 'Creando…', @@ -703,7 +711,7 @@ export const fr = { addCatalog: 'Fournisseur de modèles tiers', addCustom: 'Modèle personnalisé API', addCatalogHint: 'Choisissez OpenAI, Anthropic, Kimi ou un autre fournisseur dans le catalogue intégré et entrez sa clé API.', - addCustomHint: 'Connectez un relais, un serveur auto-hébergé ou tout autre point de terminaison compatible OpenAI ou Anthropic par son URL de base, son protocole et ses modèles.', + addCustomHint: 'Connectez un relais, un serveur auto-hébergé ou tout autre point de terminaison compatible OpenAI, Anthropic ou Gemini par son URL de base, son protocole et ses modèles.', addCatalogExhausted: 'Chaque fournisseur de catalogue est déjà configuré.', addCustomUnavailable: "Aucun protocole API n'est disponible à déclarer.", provider: 'Fournisseur', @@ -833,11 +841,13 @@ export const fr = { protocolOpenAiCompletions: 'Achèvements du chat OpenAI', protocolOpenAiResponses: 'Réponses OpenAI', protocolAnthropicMessages: 'Messages anthropiques', + protocolGemini: 'Gemini API', customNeedsBaseUrl: "Un fournisseur personnalisé a besoin d'une URL de base.", customBaseUrlInvalid: 'Saisissez une URL HTTP ou HTTPS valide.', customNeedsModels: "Un fournisseur personnalisé a besoin d'au moins un modèle.", customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: 'chemin des paramètres insolubles', create: 'Créer un fournisseur', creating: 'Création…', @@ -873,7 +883,7 @@ export const de = { addCatalog: 'Drittanbieter von Modellen', addCustom: 'Kundenspezifisches Modell API', addCatalogHint: 'Wählen Sie OpenAI, Anthropic, Kimi oder einen anderen Anbieter aus dem integrierten Katalog aus und geben Sie den API-Schlüssel ein.', - addCustomHint: 'Verbinden Sie ein Relay, einen selbst gehosteten Server oder einen anderen OpenAI- oder Anthropic-kompatiblen Endpunkt über seine Basis-URL, sein Protokoll und seine Modelle.', + addCustomHint: 'Verbinden Sie ein Relay, einen selbst gehosteten Server oder einen anderen OpenAI-, Anthropic- oder Gemini-kompatiblen Endpunkt über seine Basis-URL, sein Protokoll und seine Modelle.', addCatalogExhausted: 'Jeder Kataloganbieter ist bereits konfiguriert.', addCustomUnavailable: 'Es ist kein API-Protokoll zur Deklaration verfügbar.', provider: 'Anbieter', @@ -1003,11 +1013,13 @@ export const de = { protocolOpenAiCompletions: 'OpenAI-Chat-Abschlüsse', protocolOpenAiResponses: 'OpenAI-Antworten', protocolAnthropicMessages: 'Anthropische Botschaften', + protocolGemini: 'Gemini API', customNeedsBaseUrl: 'Ein benutzerdefinierter Anbieter benötigt eine Basis-URL.', customBaseUrlInvalid: 'Geben Sie eine gültige HTTP- oder HTTPS-URL ein.', customNeedsModels: 'Ein benutzerdefinierter Anbieter benötigt mindestens ein Modell.', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: 'Unauflösbarer Einstellungspfad', create: 'Anbieter erstellen', creating: 'Erstellen…', @@ -1043,7 +1055,7 @@ export const ru = { addCatalog: 'Сторонний поставщик моделей', addCustom: 'Пользовательская модель API', addCatalogHint: 'Выберите OpenAI, Anthropic, Kimi или другого провайдера из встроенного каталога и введите его ключ API.', - addCustomHint: 'Подключите реле, автономный сервер или любую другую конечную точку, совместимую с OpenAI или Anthropic, по ее базовому URL-адресу, протоколу и модели.', + addCustomHint: 'Подключите реле, автономный сервер или любую другую конечную точку, совместимую с OpenAI, Anthropic или Gemini, по ее базовому URL-адресу, протоколу и модели.', addCatalogExhausted: 'Каждый поставщик каталога уже настроен.', addCustomUnavailable: 'Протокол API недоступен для объявления.', provider: 'Поставщик', @@ -1173,11 +1185,13 @@ export const ru = { protocolOpenAiCompletions: 'Завершения чата OpenAI', protocolOpenAiResponses: 'Ответы OpenAI', protocolAnthropicMessages: 'Антропные сообщения', + protocolGemini: 'Gemini API', customNeedsBaseUrl: 'Пользовательскому поставщику нужен базовый URL-адрес.', customBaseUrlInvalid: 'Введите действительный URL-адрес HTTP или HTTPS.', customNeedsModels: 'Пользовательскому поставщику нужна хотя бы одна модель.', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: 'неразрешимый путь настроек', create: 'Создать поставщика', creating: 'Создание…', @@ -1213,7 +1227,7 @@ export const pt = { addCatalog: 'Provedor de modelo terceirizado', addCustom: 'Modelo personalizado API', addCatalogHint: 'Escolha OpenAI, Anthropic, Kimi ou outro provedor no catálogo integrado e insira sua chave API.', - addCustomHint: 'Conecte um retransmissor, um servidor auto-hospedado ou qualquer outro endpoint compatível com OpenAI ou Anthropic por meio de URL, protocolo e modelos básicos.', + addCustomHint: 'Conecte um retransmissor, um servidor auto-hospedado ou qualquer outro endpoint compatível com OpenAI, Anthropic ou Gemini por meio de URL, protocolo e modelos básicos.', addCatalogExhausted: 'Cada provedor de catálogo já está configurado.', addCustomUnavailable: 'Nenhum protocolo API está disponível para declaração.', provider: 'Provedor', @@ -1343,11 +1357,13 @@ export const pt = { protocolOpenAiCompletions: 'Conclusões do bate-papo OpenAI', protocolOpenAiResponses: 'Respostas OpenAI', protocolAnthropicMessages: 'Mensagens Antrópicas', + protocolGemini: 'Gemini API', customNeedsBaseUrl: 'Um provedor personalizado precisa de um URL base.', customBaseUrlInvalid: 'Insira um URL HTTP ou HTTPS válido.', customNeedsModels: 'Um provedor personalizado precisa de pelo menos um modelo.', customBaseUrlPlaceholder: 'https://gateway.example/v1', customAnthropicBaseUrlPlaceholder: 'https://gateway.example', + customGeminiBaseUrlPlaceholder: 'https://gateway.example/v1beta', settingsPathUnresolvable: 'caminho de configurações insolúveis', create: 'Criar provedor', creating: 'Criando…', diff --git a/packages/client/ui-settings-models/src/client/protocol-label.ts b/packages/client/ui-settings-models/src/client/protocol-label.ts index f65323f32c..4940f17fda 100644 --- a/packages/client/ui-settings-models/src/client/protocol-label.ts +++ b/packages/client/ui-settings-models/src/client/protocol-label.ts @@ -11,6 +11,7 @@ const PROTOCOL_LABEL_KEYS: Readonly> = { 'openai-completions': 'protocolOpenAiCompletions', 'openai-responses': 'protocolOpenAiResponses', 'anthropic-messages': 'protocolAnthropicMessages', + 'google-generative-ai': 'protocolGemini', } /** diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index a0e15d75fb..112aa17c1f 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -22,7 +22,7 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] -const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages'] +const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'] const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] /** The pi-ai profile shape as the host serializes it, including the layer-1 fields. */ @@ -1545,6 +1545,8 @@ describe('hand-declared providers', () => { expect(baseUrl.placeholder).toBe('https://gateway.example/v1') fireEvent.change(protocol, { target: { value: 'openai-responses' } }) expect(baseUrl.placeholder).toBe('https://gateway.example/v1') + fireEvent.change(protocol, { target: { value: 'google-generative-ai' } }) + expect(baseUrl.placeholder).toBe('https://gateway.example/v1beta') fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(baseUrl, { target: { value: 'https://acme.test/anthropic' } }) @@ -1609,14 +1611,15 @@ describe('hand-declared providers', () => { }) it('names each protocol by its product name and falls back to the identifier of an unknown one', () => { - mountCard({ protocols: [...PROTOCOLS, 'google-generative-ai'] }) + mountCard({ protocols: [...PROTOCOLS, 'mistral-conversations'] }) const protocol = screen.getByLabelText(en.customApi) const labels = [...protocol.options].map(option => [option.value, option.textContent]) expect(labels).toEqual([ ['openai-completions', en.protocolOpenAiCompletions], ['openai-responses', en.protocolOpenAiResponses], ['anthropic-messages', en.protocolAnthropicMessages], - ['google-generative-ai', 'google-generative-ai'], + ['google-generative-ai', en.protocolGemini], + ['mistral-conversations', 'mistral-conversations'], ]) }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1d5b274a5a..3a09b383a3 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -36,8 +36,8 @@ en: 92f554e1718614ca zh: f17ed3ad3e5d28e2 /deepseek-ai-dsh-llm-pi-ai/use-this-package/discover-models-from-endpoints: - en: 6619e8cfe7555c73 - zh: b2f80c25acb81cf8 + en: ac930f29006456cf + zh: 8d37c47f02761452 /deepseek-ai-dsh-llm-pi-ai/use-this-package/failures-and-recovery: en: 3c1edbc63de24a1a zh: 01592718c3a2c79f @@ -93,8 +93,8 @@ en: b6bd570c0159ba08 zh: 7d2d91eea6a8d830 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work: - en: 07e32eef563bb747 - zh: 225bde55120955f9 + en: 418958b038953533 + zh: d44270ceff79d9ad /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note: en: 3678dd0f6642c2a5 zh: 75cbdd064cc64282 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 5577269ec1..b16e7048e6 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -116,7 +116,7 @@ Each operation captures the current `providers` Config reference. New or changed ### Discover models from endpoints -The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `cordis.patch.yml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `cordis.patch.yml` remains the only thing that decides what a route serves. +The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. `google-generative-ai` uses `GET {baseURL}/models?pageSize=1000` with `x-goog-api-key`, because its `baseURL` already carries the API version, such as `/v1beta`. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `cordis.patch.yml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. A Gemini reply is a `models` array instead: each `name` without its `models/` prefix becomes the request id, `displayName`, `inputTokenLimit`, and `outputTokenLimit` fill the same fields, and an entry whose `supportedGenerationMethods` omits `generateContent` is skipped. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `cordis.patch.yml` remains the only thing that decides what a route serves. ### Failures and recovery @@ -263,10 +263,11 @@ These limits define where the adapter stops and future work begins. They are cur - **Complete Config replacement can remove inherited dictionary entries** — a field reset instead restores its inherited value. - **`headers` can carry a credential the redactor never sees** — profile resolution rejects names and values Fetch cannot represent, but the dict remains plain strings; store credentials as `apiKeyEnv` references. - **Discovery does not change configured models** — adopt discovery results explicitly into the route configuration. -- **Anthropic discovery reads at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more`; entries beyond the first page must be added by hand. +- **Anthropic and Gemini discovery read at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more` or `nextPageToken`; entries beyond the first page must be added by hand. - **Uncataloged models need an unambiguous protocol** — catalog routes preserve each model's protocol, including both Azure APIs under `azure`. A model absent from a mixed-protocol catalog needs an explicit route-level `api`, which overrides every model on the route and must be one of the supported custom protocols. For Azure deployment names, keep the catalog model ID and set `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. -- **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. +- **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. pi-ai's Gemini implementation refuses a request without an API key, so a `google-generative-ai` route always needs `apiKeyEnv`, whose value it sends as `x-goog-api-key`. +- **Gemini reasoning levels follow pi-ai's Gemini mapping** — on `google-generative-ai`, each `reasoningEfforts` value must resolve to `minimal`, `low`, `medium`, or `high`, or the request fails. Gemini 3 and later and `*-latest` model ids receive it as `thinkingLevel`; other ids receive a thinking-token budget chosen from the model id or `thinkingBudgets`. `off` sends a zero thinking budget to those other ids, which models that cannot disable thinking, such as Gemini 2.5 Pro, reject. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers. - **Conversation update support comes only from the installed pi-ai catalog** — native system and tool updates require the catalog model’s mid-conversation compat flags, which profiles cannot set. Hand-declared routes and unflagged models receive later system text as user-role reminders and the complete current tool list at the start of each request. - **Images are accepted only in user and tool-result messages** — images in system, developer, or assistant history fail with `UNSUPPORTED_CONTENT` on both conversion paths. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index ea9f18fae1..a5ff63c243 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -116,7 +116,7 @@ profile 的 `models` 列表会替换而非扩展路由的已安装目录;每 ### 从端点发现模型 -插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `cordis.patch.yml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`cordis.patch.yml` 仍然是决定路由服务内容的唯一事实。 +插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。`google-generative-ai` 以 `x-goog-api-key` 使用 `GET {baseURL}/models?pageSize=1000`,因为其 `baseURL` 已包含 API 版本,例如 `/v1beta`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `cordis.patch.yml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。Gemini 的回答则是 `models` 数组:每个 `name` 去掉 `models/` 前缀后成为请求 id,`displayName`、`inputTokenLimit` 与 `outputTokenLimit` 填入相同字段,`supportedGenerationMethods` 不含 `generateContent` 的条目会被跳过。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`cordis.patch.yml` 仍然是决定路由服务内容的唯一事实。 ### 失败与恢复 @@ -263,10 +263,11 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **完整替换 Config 可以移除继承的字典条目**——字段重置则恢复其继承值。 - **`headers` 可以携带 redactor 永远看不到的凭据**——profile 解析会拒绝 Fetch 无法表示的名称与值,但该字典仍是纯字符串;以 `apiKeyEnv` 引用存储凭据。 - **发现操作不更改已配置模型**——需显式将发现结果采纳到路由配置中。 -- **Anthropic 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more`;第一页之外的条目需要手工添加。 +- **Anthropic 与 Gemini 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more` 或 `nextPageToken`;第一页之外的条目需要手工添加。 - **目录外模型需要明确的协议**——目录路由保留每个模型的协议,包括 `azure` 下的两种 Azure API。混合协议目录中不存在的模型需要显式设置路由级 `api`;该设置覆盖路由上的所有模型,且必须属于受支持的自定义协议。Azure 部署名称应通过 `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` 设置,模型 ID 保持为目录中的 ID。 - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 -- **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。 +- **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。pi-ai 的 Gemini 实现会拒绝没有 API 密钥的请求,因此 `google-generative-ai` 路由始终需要 `apiKeyEnv`,其值以 `x-goog-api-key` 发送。 +- **Gemini 推理等级遵循 pi-ai 的 Gemini 映射**——在 `google-generative-ai` 上,每个 `reasoningEfforts` 值必须解析为 `minimal`、`low`、`medium` 或 `high`,否则请求失败。Gemini 3 及更新版本与 `*-latest` 模型 id 以 `thinkingLevel` 接收该值;其他 id 接收按模型 id 或 `thinkingBudgets` 选出的思考 token 预算。`off` 会向这些其他 id 发送零思考预算,无法关闭思考的模型(例如 Gemini 2.5 Pro)会拒绝该请求。 - **不支持 `GenerateOptions.stop`**——pi-ai 的通用流式选项无法跨提供方保证停止序列行为。 - **对话更新支持只来自已安装的 pi-ai 目录**——原生系统与工具更新需要目录模型的对话中 compat 标志,profile 不能设置这些标志。手工声明的路由与未标记的模型会以用户角色提醒接收后续系统文本,并在每次请求开头接收完整的当前工具列表。 - **图片只在 user 与工具结果消息中被接受**——system、developer 或 assistant 历史中的图片在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index d171b30235..22266a1005 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -13,9 +13,10 @@ * metadata the surface offers for adoption. `cordis.patch.yml` remains the only * thing that decides what a route serves. * - * OpenAI-compatible and Anthropic Messages protocols are interrogated through - * their native model-listing endpoints. The parser accepts the standard - * `data` array and the enriched `models` map some compatible gateways expose. + * OpenAI-compatible, Anthropic Messages, and Gemini protocols are interrogated + * through their native model-listing endpoints. The OpenAI and Anthropic parser + * accepts the standard `data` array and the enriched `models` map some + * compatible gateways expose; Gemini replies with its own `models` array. * Every other protocol reports that it cannot be interrogated so the surface * falls back to hand-entry rather than guessing its response fields. * @@ -30,7 +31,8 @@ import { catalogModels } from './catalog.ts' /** * Protocols whose model listing this module can read. OpenAI protocols use * bearer auth at `GET {baseURL}/models`; Anthropic Messages uses `x-api-key` - * and `anthropic-version` at its native `GET /v1/models`. Azure is absent + * and `anthropic-version` at its native `GET /v1/models`; Gemini uses + * `x-goog-api-key` at `GET {baseURL}/models`. Azure is absent * despite its OpenAI lineage — it authenticates with an `api-key` header and * requires an `api-version` query — and Codex authenticates through OAuth; * guessing at either would report an authentication failure as a provider @@ -38,6 +40,7 @@ import { catalogModels } from './catalog.ts' */ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ 'anthropic-messages', + 'google-generative-ai', 'openai-completions', 'openai-responses', ]) @@ -48,6 +51,9 @@ const ANTHROPIC_VERSION = '2023-06-01' /** Largest model-list page accepted by Anthropic's public endpoint; discovery reads one page and does not follow `has_more`. */ const ANTHROPIC_MODEL_LIMIT = 1000 +/** Largest model-list page accepted by the Gemini API; discovery reads one page and does not follow `nextPageToken`. */ +const GEMINI_MODEL_LIMIT = 1000 + /** * Endpoint replies larger than this are refused. The endpoint is whatever URL * the user typed, so the ceiling holds on the bytes actually read rather than @@ -108,14 +114,16 @@ function label(...candidates: readonly unknown[]): string | undefined { * treated as a prefix rather than a URL to resolve against, so a deployment * path such as `https://gateway.example/openai/v1` keeps its segments instead * of losing them to `URL` resolution. OpenAI protocols list at - * `{baseURL}/models`. Anthropic lists at `{root}/v1/models`, where the root is - * the base without trailing slashes and without one trailing `/v1` segment: - * gateway documentation publishes both spellings of the same root. Only this - * listing URL normalizes that segment; model requests receive the configured - * `baseURL` unchanged. + * `{baseURL}/models`, and Gemini at `{baseURL}/models` with a page size, + * because its `baseURL` already carries the API version. Anthropic lists at + * `{root}/v1/models`, where the root is the base without trailing slashes and + * without one trailing `/v1` segment: gateway documentation publishes both + * spellings of the same root. Only this listing URL normalizes that segment; + * model requests receive the configured `baseURL` unchanged. */ function listingUrl(baseURL: string, api: string): string { const base = baseURL.replace(/\/+$/, '') + if (api === 'google-generative-ai') return `${base}/models?pageSize=${String(GEMINI_MODEL_LIMIT)}` if (api !== 'anthropic-messages') return `${base}/models` const root = base.endsWith('/v1') ? base.slice(0, -3) : base return `${root}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}` @@ -229,6 +237,49 @@ function readListing(body: unknown): LlmDiscoveredModel[] { return models } +/** One entry of a Gemini `GET /models` reply. */ +interface GeminiListingEntry { + name?: unknown + displayName?: unknown + inputTokenLimit?: unknown + outputTokenLimit?: unknown + supportedGenerationMethods?: unknown +} + +/** + * Read a Gemini model-listing reply. Each entry's `name` is a resource name + * such as `models/gemini-2.5-pro`; the request id is that name without the + * `models/` prefix, because pi-ai's Gemini protocol sends the bare id. Entries + * that declare generation methods without `generateContent`, such as embedding + * models, cannot serve a conversation and are skipped. + */ +function readGeminiListing(body: unknown): LlmDiscoveredModel[] { + const listed = (body as { models?: unknown } | null)?.models + if (!Array.isArray(listed)) { + throw new LlmError( + 'the endpoint\'s model listing has no "models" array; enter this provider\'s models by hand', + 'DISCOVERY_FAILED', + ) + } + const models: LlmDiscoveredModel[] = [] + for (const raw of listed as readonly unknown[]) { + const entry = raw as GeminiListingEntry | null + const methods = entry?.supportedGenerationMethods + if (Array.isArray(methods) && !methods.includes('generateContent')) continue + const id = label(entry?.name)?.replace(/^models\//, '') + if (id === undefined || id.length === 0) continue + const contextWindow = capacity(entry?.inputTokenLimit) + const maxTokens = capacity(entry?.outputTokenLimit) + models.push({ + id, + name: label(entry?.displayName) ?? id, + ...contextWindow === undefined ? {} : { contextWindow }, + ...maxTokens === undefined ? {} : { maxTokens }, + }) + } + return models +} + /** * Accept one probe key, or refuse it before the header is built. Without this * the `fetch` below would throw a ByteString `TypeError` that this function's @@ -320,6 +371,8 @@ export async function discoverModels( if (api === 'anthropic-messages') { headers.set('anthropic-version', ANTHROPIC_VERSION) if (apiKey !== undefined) headers.set('x-api-key', apiKey) + } else if (api === 'google-generative-ai') { + if (apiKey !== undefined) headers.set('x-goog-api-key', apiKey) } else if (apiKey !== undefined) { headers.set('authorization', `Bearer ${apiKey}`) } @@ -359,5 +412,5 @@ export async function discoverModels( } catch (error: unknown) { throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error }) } - return readListing(body) + return api === 'google-generative-ai' ? readGeminiListing(body) : readListing(body) } diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index 9a851f6ef8..af6edea801 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -21,6 +21,7 @@ import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai' import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy' +import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy' import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' import { catalogProvider, PiAiCatalogError } from './catalog.ts' @@ -48,6 +49,7 @@ const PROTOCOLS: Readonly ProviderStreams>> = { 'openai-completions': openAICompletionsApi, 'openai-responses': openAIResponsesApi, 'anthropic-messages': anthropicMessagesApi, + 'google-generative-ai': googleGenerativeAIApi, } /** diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index bb3862e78a..188f07f448 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -220,6 +220,52 @@ describe('draft-provider model discovery', () => { expect(server.headers.map(headers => headers['user-agent'])).toEqual([userAgent(), userAgent(), userAgent()]) }) + it('uses the Gemini model-listing path, header, and fields', async () => { + const server = await listingServer({ + body: JSON.stringify({ + models: [ + { + name: 'models/gemini-pro', + displayName: 'Gemini Pro', + inputTokenLimit: 1_048_576, + outputTokenLimit: 65_536, + supportedGenerationMethods: ['generateContent', 'countTokens'], + }, + { name: 'models/text-embedding', supportedGenerationMethods: ['embedContent'] }, + { name: 'gateway-alias' }, + { name: 'models/' }, + { displayName: 'nameless' }, + ], + nextPageToken: 'ignored', + }), + }) + const ctx = await harness() + + const models = await ctx.llm.discoverModels('llm-pi-ai', { + baseURL: `${server.url}/v1beta/`, + api: 'google-generative-ai', + apiKey: 'gemini-key', + }) + + expect(models).toEqual([ + { id: 'gemini-pro', name: 'Gemini Pro', contextWindow: 1_048_576, maxTokens: 65_536 }, + { id: 'gateway-alias', name: 'gateway-alias' }, + ]) + expect(server.paths).toEqual(['/v1beta/models?pageSize=1000']) + expect(server.headers[0]?.['x-goog-api-key']).toBe('gemini-key') + expect(server.headers[0]?.authorization).toBeUndefined() + }) + + it('leaves a Gemini probe with no key unauthenticated, and rejects a reply with no models array', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = await harness() + + const failure = ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url, api: 'google-generative-ai' }) + await expect(failure).rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) + await expect(failure).rejects.toThrow(/no "models" array/) + expect(server.headers[0]?.['x-goog-api-key']).toBeUndefined() + }) + it('prefers the standard data array when both supported formats are present', async () => { const server = await listingServer({ body: JSON.stringify({ @@ -379,7 +425,7 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) }) - it.each(['azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])( + it.each(['azure-openai-responses', 'openai-codex-responses'])( 'says it cannot interrogate %s rather than guessing a shape', async (api) => { // Azure authenticates with an `api-key` header and an `api-version` diff --git a/packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts b/packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts new file mode 100644 index 0000000000..106b449c5c --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/gemini-protocol.spec.ts @@ -0,0 +1,83 @@ +/** A hand-declared route can speak the Gemini API protocol. */ + +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createMessage } from '@deepseek-ai/dsh-llm' +import * as PiAi from '../src/index.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer } from './mock-server.ts' + +afterEach(async () => { + await closeMockServers() + vi.unstubAllEnvs() +}) + +/** One complete Gemini stream reply carrying `parts`. */ +function reply(parts: Record[]): { events: string[] } { + return { events: [JSON.stringify({ + candidates: [{ content: { role: 'model', parts }, finishReason: 'STOP', index: 0 }], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1, totalTokenCount: 4 }, + })] } +} + +async function gateway(url: string): Promise { + vi.stubEnv('GEMINI_GATEWAY_KEY', 'gemini-key') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LlmRuntime) + await ctx.plugin(PiAi, { providers: { 'gemini-gateway': { + apiKeyEnv: 'GEMINI_GATEWAY_KEY', + api: 'google-generative-ai', + baseURL: `${url}/v1beta`, + models: [{ id: 'gemini-test' }], + } } }) + return ctx +} + +it('streams a hand-declared Gemini route from its configured endpoint', async () => { + const server = await mockServer([reply([{ text: 'hello' }])]) + const ctx = await gateway(server.url) + + const result = await assemble(ctx, { + provider: 'gemini-gateway', + model: 'gemini-test', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }) + + expect(result.finish).toEqual({ kind: 'stop' }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.paths).toEqual(['/v1beta/models/gemini-test:streamGenerateContent?alt=sse']) + expect(server.headers[0]?.['x-goog-api-key']).toBe('gemini-key') +}) + +it('returns a Gemini function call and replays it with its result', async () => { + const server = await mockServer([ + reply([{ functionCall: { name: 'count', args: { value: 2 } }, thoughtSignature: 'c2ln' }]), + reply([{ text: 'two' }]), + ]) + const ctx = await gateway(server.url) + const tools = [{ name: 'count', description: 'Count', parameters: { type: 'object', properties: { value: { type: 'number' } } } }] + const user = { role: 'user' as const, content: [{ type: 'text' as const, text: 'count' }] } + + const call = await assemble(ctx, { provider: 'gemini-gateway', model: 'gemini-test', tools, messages: [user] }) + const [block] = call.message.content + if (block?.type !== 'tool-call') throw new Error('expected a tool call') + expect(call.finish).toEqual({ kind: 'tool-calls' }) + expect(block).toMatchObject({ name: 'count', arguments: '{"value":2}' }) + expect(server.requests[0]).toMatchObject({ tools: [{ functionDeclarations: [{ name: 'count' }] }] }) + + const answer = await assemble(ctx, { + provider: 'gemini-gateway', + model: 'gemini-test', + tools, + messages: [user, call.message, createMessage({ + role: 'tool', source: { kind: 'tool', callId: block.id }, toolCallId: block.id, content: [{ type: 'text', text: '2' }], + })], + }) + expect(answer.message.content).toEqual([{ type: 'text', text: 'two' }]) + expect(server.requests[1]).toMatchObject({ contents: [ + { role: 'user' }, + { role: 'model', parts: [{ functionCall: { name: 'count', args: { value: 2 } }, thoughtSignature: 'c2ln' }] }, + { role: 'user', parts: [{ functionResponse: { name: 'count' } }] }, + ] }) +}) From c0af425caea2ce916068a7e5ac0cef8d4e19fd4c Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 5 Nov 2026 20:10:05 +0800 Subject: [PATCH 5/6] test(web): expect the Gemini API protocol in Models settings goldens Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com> --- .../tests/expected/models-settings/declared-edit.expected.md | 1 + apps/web/tests/expected/models-settings/image-only.expected.md | 1 + .../tests/expected/models-settings/mobile-collapsed.expected.md | 1 + .../tests/expected/models-settings/mobile-expanded.expected.md | 1 + apps/web/tests/models-settings.e2e.ts | 2 +- 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 85aa3923d0..c8f652fbf3 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" [selected] - option "OpenAI Responses" - option "Anthropic Messages" + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 9645929155..051df42792 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md index 2f573f6e53..c052e8c61f 100644 --- a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md index 5e0855c653..69444fb808 100644 --- a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md @@ -36,6 +36,7 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - option "Gemini API" - group "重试策略": - text: 重试策略 - combobox "重试策略": diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 58d76d04f1..950076a427 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -298,7 +298,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The custom-API mode is the second segment of the one add card; its // purpose line replaces the catalog one. await dialog.getByRole('tab', { name: '自定义模型 API' }).click() - await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 }) + await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 }) // The third-party panel stays mounted, hidden, beside this one, so the // form is addressed through its own panel. const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' }) From d9768b25179cea6122b4e4791ee7ec9e27486e11 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 5 Nov 2026 21:05:37 +0800 Subject: [PATCH 6/6] fix(ui-settings-models): require a key and the four Gemini reasoning levels A Gemini API route cannot authenticate without a key, so the create card and the editor require one. Its models offer only minimal, low, medium, and high, each sent under its own name, with no Off. Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com> --- .../ui-settings-models/README.i18n.yaml | 8 +- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/CustomProviderCard.tsx | 22 +++-- .../src/client/ModelListEditor.tsx | 3 + .../src/client/ModelReasoningEfforts.tsx | 41 +++++++-- .../src/client/ModelRow.tsx | 5 +- .../src/client/ProviderEditor.tsx | 43 ++++++--- .../ui-settings-models/src/client/locales.ts | 8 ++ .../src/client/protocol-rules.ts | 44 ++++++++++ .../model-reasoning-efforts.client.spec.tsx | 43 ++++++++- .../tests/provider-form.client.spec.tsx | 87 ++++++++++++++++++- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/discovery.ts | 2 + packages/llm/llm-pi-ai/src/provider.ts | 3 +- 17 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 packages/client/ui-settings-models/src/client/protocol-rules.ts diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 6c1b573574..74ecbbb6d5 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,11 +21,11 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 03a9953b8aa0c97a - zh: f3a5bcaa34193bcd + en: a0501172f15c3286 + zh: 6dfa112f215b2d3d /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: - en: 7ef7686925d0e4e1 - zh: 92b9dba0a9dd08c1 + en: 59472e717c2f87be + zh: dc2a51a5a40138bc /deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs: en: 589397a35f82244e zh: 342fbceee2c173a2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 8da186a81c..5039f7988d 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -63,13 +63,13 @@ Drag a model’s reorder handle to change its position within the provider, or f Expand **Customized settings → Model options** to edit each model. When the model row is narrow, the main row shows only the model ID; expand the row to view or edit its display name. Wider rows show both fields side by side. Focusing a display name expands its row and keeps it open until explicitly collapsed. Tab reaches the option and delete buttons before the display name at every width. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. -A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. +A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. On a Gemini API route the level select offers only `minimal`, `low`, `medium`, and `high`, turning the switch on starts from low, medium, and high, and each value field is disabled and holds the level name, which changing a row's level keeps; the Gemini implementation accepts only those four names as values. A stored level, or a value other than its level name, blocks saving with a message under the rows, in the create card and in the editor alike; the editor checks inherited models as well once the protocol is Gemini. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. Empty pi-ai capacity fields display `1M` for the context window and `128K` for maximum output tokens. These hints use decimal K/M units; leaving the fields empty preserves adapter inheritance. Provider-level defaults can differ from the hints. ### Adding and deleting providers -**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI-, Anthropic-, or Gemini-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, Gemini API — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`, and Gemini API shows `https://gateway.example/v1beta` because its base URL carries the API version. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI-, Anthropic-, or Gemini-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. A Gemini API route also needs an API key, because pi-ai's Gemini implementation refuses a request without one: the create card, and the editor of such a route whose profile names no `apiKeyEnv`, mark the key field required, show that requirement as a hint under it, and block saving and **Fetch available models** until a key is typed. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, Gemini API — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`, and Gemini API shows `https://gateway.example/v1beta` because its base URL carries the API version. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 34fb41b61e..a6a1595bda 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -63,13 +63,13 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 展开**自定义设置 → 模型选项**编辑模型。模型行较窄时,主行仅显示模型 ID,展开模型行后可查看或编辑显示名称;宽度充足时,两个输入框并排显示。显示名称获得焦点时会展开该行,并保持展开,直到显式收起。所有宽度下,Tab 都先经过模型选项和删除按钮,再到显示名称。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 -手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 +手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。在 Gemini API 路由上,档位选择框只提供 `minimal`、`low`、`medium` 和 `high`,打开开关从 low、medium 和 high 三行开始,参数值输入框全部禁用并保持为档位名,修改某行档位时也随之改为新档位名;Gemini 实现只接受这四个名称作为参数值。已存储的超出范围的档位,或与档位名不同的参数值,会在行下方显示提示并阻止保存,新建卡片与编辑器都是如此;协议为 Gemini 时,编辑器也会检查继承的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token 数显示 `128K`。这些提示使用十进制 K/M 单位;保留空值会继续继承适配器配置。提供方级默认值可能与提示不同。 ### 新增与删除提供商 -**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages、Gemini API——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`;Gemini API 显示 `https://gateway.example/v1beta`,因为其基础 URL 包含 API 版本。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic / Gemini 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。Gemini API 路由还需要 API 密钥,因为 pi-ai 的 Gemini 实现会拒绝没有密钥的请求:新建卡片,以及 profile 未声明 `apiKeyEnv` 的此类路由的编辑器,会把密钥字段标为必填,在其下方以提示说明这一要求,并在输入密钥前阻止保存和**获取可用模型**。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages、Gemini API——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`;Gemini API 显示 `https://gateway.example/v1beta`,因为其基础 URL 包含 API 版本。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 408df27dba..efa93e28ab 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -36,8 +36,9 @@ import type { ModelDraft } from './ModelListEditor.tsx' import { validateReasoningEfforts } from './ModelReasoningEfforts.tsx' import { deriveKeyRef } from './store.ts' import { protocolLabel } from './protocol-label.ts' +import { protocolNeedsKey, protocolReasoningLevels } from './protocol-rules.ts' import type { ModelsOperations } from './operations.ts' -import type { en } from './locales.ts' +import type { en, ModelsKey } from './locales.ts' import styles from './ModelsSection.module.css' /** The settings namespace a hand-declared provider is written into. */ @@ -58,7 +59,7 @@ const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ * OpenAI-compatible `/v1`: Anthropic's SDK appends `/v1/messages`, and the * Gemini protocol expects the API version in the base URL. */ -const BASE_URL_PLACEHOLDERS: Readonly> = { +const BASE_URL_PLACEHOLDERS: Readonly> = { 'anthropic-messages': 'customAnthropicBaseUrlPlaceholder', 'google-generative-ai': 'customGeminiBaseUrlPlaceholder', } @@ -138,8 +139,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // bad row is named by its position here too. Capacities have route-level // fallbacks; what a route cannot default is at least one model. Every row of // this hand-declared route edits its reasoning levels, so they are checked too. - const modelFailure = validateDeepSeekModels(models) ?? validateReasoningEfforts(models) - const keyFailure = apiKeyFailure(keyDraft) + const reasoning = protocolReasoningLevels(protocol, reasoningLevels) + const modelFailure = validateDeepSeekModels(models) + ?? validateReasoningEfforts(models, reasoning.levelsOnly ? reasoning.levels : undefined) + // A blank key is a legal choice only where the protocol can authenticate without one. + const keyMissing = protocolNeedsKey(protocol) && keyDraft.trim().length === 0 + const keyFailure = keyMissing ? 'keyRequired' as const : apiKeyFailure(keyDraft) // The typed key with paste whitespace removed. A blank field yields an empty // string, which the create path reads as "no key supplied" — a route may // legitimately authenticate through the provider's own ambient discovery. @@ -286,20 +291,23 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={keyDraft} placeholder={t('keyPlaceholder')} aria-label={t('keyInput')} + required={protocolNeedsKey(protocol)} disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> {/* A create card has no stored key to keep, so the blank case says what a blank field means here instead: this route may authenticate - through the provider's own ambient discovery or OAuth. */} + through the provider's own ambient discovery or OAuth. A key the + protocol requires is a requirement still to meet, not a fault. */} {keyFailure === undefined ? null - :

{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

} + :

{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

}
outsideLevels(only, level, wire))) { + return { index, key: 'modelReasoningLevelsOnly' } + } if (entries.some(([level, wire]) => !acceptedValue(level, wire))) return { index, key: 'modelReasoningValueRequired' } if (!entries.some(([level]) => level !== 'off')) return { index, key: 'modelReasoningNeedsLevel' } } @@ -90,6 +107,8 @@ interface ModelReasoningEffortsProps { model: DeepSeekModelDraft /** Levels the adapter's schema accepts as `reasoningEfforts` keys, in escalation order. */ levels: readonly string[] + /** Whether the route's protocol accepts only {@link levels}, each sent under its own name. */ + levelsOnly: boolean /** One-based row position for accessible names. */ position: number /** Prevent changes while read-only or saving. */ @@ -107,7 +126,8 @@ interface ModelReasoningEffortsProps { * @param props - model declaration and row replacement action. * @returns the reasoning-level group. */ -export function ModelReasoningEfforts({ model, levels, position, disabled, t, onChange }: ModelReasoningEffortsProps): ReactNode { +export function ModelReasoningEfforts(props: ModelReasoningEffortsProps): ReactNode { + const { model, levels, levelsOnly, position, disabled, t, onChange } = props const entries = readEfforts(model) // Only the focused field keeps its own text, so a field cleared while typing // stays empty although its level's name is stored. Leaving the field drops @@ -124,7 +144,8 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on const changeLevel = (current: Entries, index: number, from: string, wire: unknown, to: string): void => { // Only a custom spelling between two thinking levels carries over; a value // still spelled as its old level, or one crossing to or from Off, resets. - const carried = typeof wire === 'string' && wire.length > 0 && wire !== from && from !== 'off' && to !== 'off' + // A protocol accepting only its offered levels sends each under its own name, so nothing carries. + const carried = !levelsOnly && typeof wire === 'string' && wire.length > 0 && wire !== from && from !== 'off' && to !== 'off' write(replaceAt(current, index, [to, carried ? wire : defaultValue(to)])) } @@ -158,6 +179,7 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on const refused = entries?.some(([level, wire]) => !acceptedValue(level, wire)) === true const missingLevel = entries !== undefined && !used.some(level => level !== 'off') + const outside = levelsOnly && entries?.some(([level, wire]) => outsideLevels(levels, level, wire)) === true return (
@@ -212,7 +234,8 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on aria-label={`${level} ${t('modelReasoningValue')}`} aria-invalid={!acceptedValue(level, wire)} // Off only turns reasoning off; a value stored in cordis.patch.yml is shown and kept. - disabled={disabled || level === 'off'} + // A protocol accepting only its offered levels sends each under its own name. + disabled={disabled || level === 'off' || levelsOnly} onChange={(event) => { editValue(entries, index, level, event.target.value) }} onBlur={() => { leaveValue(entries, index, level, wire) }} /> @@ -228,9 +251,11 @@ export function ModelReasoningEfforts({ model, levels, position, disabled, t, on
))} - {refused - ?

{t('modelReasoningValueRequired')}

- : missingLevel ?

{t('modelReasoningNeedsLevel')}

: null} + {outside + ?

{t('modelReasoningLevelsOnly')}

+ : refused + ?

{t('modelReasoningValueRequired')}

+ : missingLevel ?

{t('modelReasoningNeedsLevel')}

: null}
} {props.credentialOnly === true ? null :
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index f5e3e03934..ae9d4f71a4 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -108,6 +108,7 @@ export const en = { modelReasoningHelpText: 'The API value is sent in the request\'s reasoning-effort field, such as OpenAI\'s reasoning_effort. It defaults to the level name; where the model provider\'s API documentation specifies a different value, follow it. Off turns reasoning off and needs no value; a value already written in the configuration is kept and sent.', modelReasoningNeedsLevel: 'Add at least one level besides Off, or turn reasoning levels off.', modelReasoningValueRequired: 'Every level besides Off needs an API value, and no value can be empty.', + modelReasoningLevelsOnly: 'This API protocol accepts only the minimal, low, medium, and high levels, each with its level name as the API value. Change or remove the other levels.', modelReasoningAdd: 'Add level', modelReasoningRemove: 'Delete level', modelReasoningOffValuePlaceholder: 'Turns reasoning off', @@ -283,6 +284,7 @@ export const zh: { [Key in keyof typeof en]: string } = { modelReasoningHelpText: 'API 参数值会写入请求的推理强度字段(如 OpenAI 的 reasoning_effort),默认与档位名相同;如有不同,以模型提供商的 API 文档为准。Off 表示关闭思考,不需要填写参数值;配置中已写入的值会保留并发送。', modelReasoningNeedsLevel: '至少添加一个 Off 以外的档位;不需要思考请关闭推理等级。', modelReasoningValueRequired: 'Off 以外的档位都需要填写 API 参数值,且参数值不能为空字符串。', + modelReasoningLevelsOnly: '此 API 协议仅接受 minimal、low、medium 和 high 档位,且每个档位的 API 参数值须与档位名称相同。请修改或删除其他档位。', modelReasoningAdd: '添加档位', modelReasoningRemove: '删除档位', modelReasoningOffValuePlaceholder: '关闭思考', @@ -455,6 +457,7 @@ export const ja = { 'modelReasoningHelpText': 'API値はリクエストの推論強度フィールド(OpenAIのreasoning_effortなど)で送信されます。デフォルトはレベル名です。モデルプロバイダーのAPIドキュメントで別の値が指定されている場合はそれに従ってください。オフは推論を無効にするため値は不要です。設定に既に記述されている値は保持され、送信されます。', 'modelReasoningNeedsLevel': 'オフ以外のレベルを少なくとも1つ追加するか、推論レベルを無効にしてください。', 'modelReasoningValueRequired': 'オフ以外の各レベルにはAPI値が必要で、空の値は使用できません。', + 'modelReasoningLevelsOnly': 'この API プロトコルで使用できるレベルは minimal、low、medium、high のみで、各レベルの API 値はレベル名と同じである必要があります。その他のレベルを変更または削除してください。', 'modelReasoningAdd': 'レベルを追加', 'modelReasoningRemove': 'レベルを削除', 'modelReasoningOffValuePlaceholder': '推論を無効にします', @@ -627,6 +630,7 @@ export const es = { modelReasoningHelpText: 'El valor de la API se envía en el campo de esfuerzo de razonamiento de la solicitud, como reasoning_effort de OpenAI. De forma predeterminada se usa el nombre del nivel; si la documentación de la API del proveedor del modelo especifica otro valor, úselo. Desactivado deshabilita el razonamiento y no necesita un valor; cualquier valor ya guardado en la configuración se conserva y se envía.', modelReasoningNeedsLevel: 'Agregue al menos un nivel además de Desactivado o desactive los niveles de razonamiento.', modelReasoningValueRequired: 'Cada nivel además de Off necesita un valor API y ningún valor puede estar vacío.', + modelReasoningLevelsOnly: 'Este protocolo de API solo acepta los niveles minimal, low, medium y high, cada uno con su nombre de nivel como valor de API. Cambie o elimine los demás niveles.', modelReasoningAdd: 'Agregar nivel', modelReasoningRemove: 'Eliminar nivel', modelReasoningOffValuePlaceholder: 'Desactiva el razonamiento', @@ -799,6 +803,7 @@ export const fr = { modelReasoningHelpText: 'La valeur API est envoyée dans le champ d’effort de raisonnement de la requête, comme reasoning_effort chez OpenAI. Par défaut, elle correspond au nom du niveau ; si la documentation API du fournisseur du modèle indique une autre valeur, utilisez-la. Désactivé coupe le raisonnement et ne nécessite aucune valeur ; toute valeur déjà enregistrée dans la configuration est conservée et envoyée.', modelReasoningNeedsLevel: 'Ajoutez au moins un niveau en plus de Désactivé ou désactivez les niveaux de raisonnement.', modelReasoningValueRequired: 'Chaque niveau autre que Off nécessite une valeur API et aucune valeur ne peut être vide.', + modelReasoningLevelsOnly: 'Ce protocole d\'API n\'accepte que les niveaux minimal, low, medium et high, chacun ayant son nom de niveau comme valeur d\'API. Modifiez ou supprimez les autres niveaux.', modelReasoningAdd: 'Ajouter un niveau', modelReasoningRemove: 'Supprimer le niveau', modelReasoningOffValuePlaceholder: 'Désactive le raisonnement', @@ -971,6 +976,7 @@ export const de = { modelReasoningHelpText: 'Der API-Wert wird im Feld für den Denkaufwand der Anfrage gesendet, etwa reasoning_effort bei OpenAI. Standardmäßig wird der Stufenname verwendet; gibt die API-Dokumentation des Modellanbieters einen anderen Wert vor, verwenden Sie diesen. Aus deaktiviert das Denken und benötigt keinen Wert; ein bereits konfigurierter Wert bleibt erhalten und wird gesendet.', modelReasoningNeedsLevel: 'Fügen Sie neben „Aus“ mindestens eine Ebene hinzu oder deaktivieren Sie die Argumentationsebenen.', modelReasoningValueRequired: 'Jede Ebene außer Off benötigt einen API-Wert und kein Wert darf leer sein.', + modelReasoningLevelsOnly: 'Dieses API-Protokoll akzeptiert nur die Stufen minimal, low, medium und high, jeweils mit dem Stufennamen als API-Wert. Ändern oder entfernen Sie die anderen Stufen.', modelReasoningAdd: 'Ebene hinzufügen', modelReasoningRemove: 'Ebene löschen', modelReasoningOffValuePlaceholder: 'Schaltet das Denken aus', @@ -1143,6 +1149,7 @@ export const ru = { modelReasoningHelpText: 'Значение API передаётся в поле уровня рассуждений запроса, например reasoning_effort у OpenAI. По умолчанию используется название уровня; если документация API поставщика модели указывает другое значение, используйте его. Выкл. отключает рассуждения и не требует значения; уже записанное в конфигурации значение сохраняется и отправляется.', modelReasoningNeedsLevel: 'Добавьте хотя бы один уровень помимо «Выкл.» или отключите уровни рассуждения.', modelReasoningValueRequired: 'Для каждого уровня, кроме Off, требуется значение API, и ни одно значение не может быть пустым.', + modelReasoningLevelsOnly: 'Этот протокол API принимает только уровни minimal, low, medium и high, и значение API у каждого должно совпадать с названием уровня. Измените или удалите остальные уровни.', modelReasoningAdd: 'Добавить уровень', modelReasoningRemove: 'Удалить уровень', modelReasoningOffValuePlaceholder: 'Отключает рассуждения', @@ -1315,6 +1322,7 @@ export const pt = { modelReasoningHelpText: 'O valor da API é enviado no campo de esforço de raciocínio da solicitação, como reasoning_effort da OpenAI. Por padrão, usa-se o nome do nível; se a documentação da API do provedor do modelo especificar outro valor, use-o. Desativado desliga o raciocínio e não exige valor; qualquer valor já salvo na configuração é mantido e enviado.', modelReasoningNeedsLevel: 'Adicione pelo menos um nível além de Desativado ou desative os níveis de raciocínio.', modelReasoningValueRequired: 'Cada nível além de Desligado precisa de um valor API e nenhum valor pode ficar vazio.', + modelReasoningLevelsOnly: 'Este protocolo de API aceita apenas os níveis minimal, low, medium e high, cada um com o nome do nível como valor da API. Altere ou remova os outros níveis.', modelReasoningAdd: 'Adicionar nível', modelReasoningRemove: 'Excluir nível', modelReasoningOffValuePlaceholder: 'Desativa o raciocínio', diff --git a/packages/client/ui-settings-models/src/client/protocol-rules.ts b/packages/client/ui-settings-models/src/client/protocol-rules.ts new file mode 100644 index 0000000000..7dacf050ea --- /dev/null +++ b/packages/client/ui-settings-models/src/client/protocol-rules.ts @@ -0,0 +1,44 @@ +/** + * Constraints a wire protocol places on a hand-declared route that the Models + * forms check before a write, because the adapter accepts the profile and only + * the first request would fail. + */ + +/** pi-ai's Gemini protocol identifier. */ +const GEMINI = 'google-generative-ai' + +/** The only thinking levels pi-ai's Gemini protocol sends. */ +const GEMINI_LEVELS: readonly string[] = ['minimal', 'low', 'medium', 'high'] + +/** + * Whether a route speaking this protocol cannot work without an API key: + * pi-ai's Gemini implementation refuses a request without one, and no header + * or ambient credential replaces it. + * @param protocol - the route's protocol identifier, when chosen. + * @returns whether the form must require a key. + */ +export function protocolNeedsKey(protocol: string | undefined): boolean { + return protocol === GEMINI +} + +/** The reasoning levels a protocol's models may declare. */ +export interface ProtocolReasoningLevels { + /** Levels offered, in the schema's escalation order. */ + levels: readonly string[] + /** Whether only these levels are accepted, each sent under its own name. */ + levelsOnly: boolean +} + +/** + * Narrow the schema's reasoning levels to the ones a protocol accepts. pi-ai's + * Gemini protocol sends only `minimal`, `low`, `medium`, or `high` and fails a + * request carrying any other value, so a Gemini route offers those four with + * fixed values and no Off. + * @param protocol - the route's protocol identifier, when chosen. + * @param levels - levels the adapter's schema accepts. + * @returns the levels to offer and whether their values are fixed. + */ +export function protocolReasoningLevels(protocol: string | undefined, levels: readonly string[]): ProtocolReasoningLevels { + if (protocol !== GEMINI) return { levels, levelsOnly: false } + return { levels: levels.filter(level => GEMINI_LEVELS.includes(level)), levelsOnly: true } +} diff --git a/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx b/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx index 6e31ede058..a7a84026b4 100644 --- a/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-reasoning-efforts.client.spec.tsx @@ -18,13 +18,14 @@ const DEFAULTS = { off: null, low: 'low', medium: 'medium', high: 'high' } const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] /** Render the controlled group over local state, reporting every write. */ -function renderGroup(initial: DeepSeekModelDraft, options: { disabled?: boolean; levels?: readonly string[] } = {}) { +function renderGroup(initial: DeepSeekModelDraft, options: { disabled?: boolean; levels?: readonly string[]; levelsOnly?: boolean } = {}) { const writes: DeepSeekModelDraft[] = [] function Harness() { const [model, setModel] = useState(initial) return ( { writes.push(next); setModel(next) }} /> ) @@ -205,6 +206,44 @@ describe('ModelReasoningEfforts', () => { }) }) +describe('levels a protocol fixes', () => { + const GEMINI = ['minimal', 'low', 'medium', 'high'] + + it('starts without off, offers only its levels, and fixes each value to the level name', () => { + const { efforts } = renderGroup({ id: 'gemini' }, { levels: GEMINI, levelsOnly: true }) + fireEvent.click(toggle()) + expect(efforts()).toEqual({ low: 'low', medium: 'medium', high: 'high' }) + expect([...select(1).options].map(option => option.value)).toEqual(['minimal', 'low']) + expect(value('low').disabled).toBe(true) + expect(screen.queryByText(en.modelReasoningLevelsOnly)).toBeNull() + }) + + it('resets a custom value to the new level name when a row changes level', () => { + const { efforts } = renderGroup({ id: 'gemini', reasoningEfforts: { high: 'ultra' } }, { levels: GEMINI, levelsOnly: true }) + fireEvent.change(select(1), { target: { value: 'medium' } }) + expect(efforts()).toEqual({ medium: 'medium' }) + }) + + it('names a stored level or value outside the fixed levels', () => { + renderGroup({ id: 'gemini', reasoningEfforts: { off: null, high: 'ultra' } }, { levels: GEMINI, levelsOnly: true }) + expect(screen.getByText(en.modelReasoningLevelsOnly)).toBeTruthy() + }) + + it.each([ + [{ off: null, high: 'high' }], + [{ high: 'ultra' }], + [{ xhigh: 'xhigh' }], + ])('refuses %j on a protocol accepting only its own level names', (stored) => { + expect(validateReasoningEfforts([{ id: 'ok' }, { id: 'gemini', reasoningEfforts: stored }], GEMINI)) + .toEqual({ index: 1, key: 'modelReasoningLevelsOnly' }) + }) + + it('accepts its own level names', () => { + expect(validateReasoningEfforts([{ id: 'gemini', reasoningEfforts: { minimal: 'minimal', high: 'high' } }], GEMINI)) + .toBeUndefined() + }) +}) + describe('reasoning-level validation', () => { it.each([ [{}], diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 112aa17c1f..e59309e830 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -1056,6 +1056,50 @@ describe('hand-declared providers', () => { expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi]) }) + it('requires a key before a keyless declared route switches to the Gemini protocol', async () => { + const { mutate, set } = await mountSection({ + providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test/v1beta' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'google-generative-ai' } }) + + expect(screen.getByText(en.keyRequired)).toBeTruthy() + expect(screen.getByLabelText(en.keyInput).required).toBe(true) + expect(buttonNamed(en.apply).disabled).toBe(true) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gemini-key' } }) + expect(screen.queryByText(en.keyRequired)).toBeNull() + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(set).toHaveBeenCalledOnce() }) + expect(firstMutate(mutate).ops).toContainEqual( + { op: 'set', path: ['providers', 'acme-gateway', 'apiKeyEnv'], value: 'ACME_GATEWAY_API_KEY' }) + }) + + it('needs no typed key on a Gemini route that names its key reference', async () => { + await mountSection({ + providers: { 'acme-gateway': { apiKeyEnv: 'ACME_KEY', api: 'google-generative-ai', baseURL: 'https://acme.test/v1beta' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + expect(screen.queryByText(en.keyRequired)).toBeNull() + expect(screen.getByLabelText(en.keyInput).required).toBe(false) + }) + + it('blocks a protocol switch that the route\'s inherited reasoning levels would break', async () => { + await mountSection({ + providers: { 'acme-gateway': { + apiKeyEnv: 'ACME_KEY', api: 'openai-completions', baseURL: 'https://acme.test/v1', + models: [{ id: 'm', reasoningEfforts: { off: null, high: 'high' } }], + } }, + userProviders: { 'acme-gateway': { apiKeyEnv: 'ACME_KEY', api: 'openai-completions', baseURL: 'https://acme.test/v1' } }, + baseProviders: { 'acme-gateway': { models: [{ id: 'm', reasoningEfforts: { off: null, high: 'high' } }] } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'google-generative-ai' } }) + expect(buttonNamed(en.apply).disabled).toBe(true) + }) + it('renames a declared route and falls back to its id when the name is cleared', async () => { const { mutate } = await mountSection({ providers: { @@ -1611,7 +1655,7 @@ describe('hand-declared providers', () => { }) it('names each protocol by its product name and falls back to the identifier of an unknown one', () => { - mountCard({ protocols: [...PROTOCOLS, 'mistral-conversations'] }) + mountCard({ protocols: [...PROTOCOLS, 'future-protocol'] }) const protocol = screen.getByLabelText(en.customApi) const labels = [...protocol.options].map(option => [option.value, option.textContent]) expect(labels).toEqual([ @@ -1619,10 +1663,49 @@ describe('hand-declared providers', () => { ['openai-responses', en.protocolOpenAiResponses], ['anthropic-messages', en.protocolAnthropicMessages], ['google-generative-ai', en.protocolGemini], - ['mistral-conversations', 'mistral-conversations'], + ['future-protocol', 'future-protocol'], ]) }) + it('requires a key for the Gemini protocol, which cannot authenticate without one', async () => { + const { mutate, set, onClose } = mountCard() + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'gemini-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1beta' } }) + fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'google-generative-ai' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'gemini-pro' } }) + + expect(screen.getByText(en.keyRequired)).toBeTruthy() + expect(buttonNamed(en.create).disabled).toBe(true) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gemini-key' } }) + fireEvent.click(screen.getByText(en.create)) + + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(mutate).ops[0]?.value).toMatchObject({ api: 'google-generative-ai', apiKeyEnv: 'GEMINI_GATEWAY_API_KEY' }) + expect(set).toHaveBeenCalledOnce() + }) + + it('treats a whitespace key as missing on the Gemini protocol', () => { + mountCard() + fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'google-generative-ai' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + expect(screen.getByText(en.keyRequired)).toBeTruthy() + expect(screen.queryByText(en.keyBlankNew)).toBeNull() + }) + + it('offers a Gemini model only the levels the protocol accepts', () => { + mountCard() + fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'google-generative-ai' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + fireEvent.click(screen.getByRole('switch', { name: `${en.modelReasoning} 1` })) + + const level = screen.getByRole('combobox', { name: `${en.modelReasoningLevel} 1` }) + expect(level.value).toBe('low') + expect([...level.options].map(option => option.value)).toEqual(['minimal', 'low']) + expect(screen.getByRole('textbox', { name: `low ${en.modelReasoningValue}` }).disabled).toBe(true) + }) + it('refuses an unusable key on the field and blocks creation', () => { const { mutate, set } = mountCard() diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 3a09b383a3..f7c103d726 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -93,8 +93,8 @@ en: b6bd570c0159ba08 zh: 7d2d91eea6a8d830 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work: - en: 418958b038953533 - zh: d44270ceff79d9ad + en: 3a0cf0d369d25d37 + zh: 5974f328aea06aa8 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note: en: 3678dd0f6642c2a5 zh: 75cbdd064cc64282 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index b16e7048e6..c063137301 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -267,7 +267,7 @@ These limits define where the adapter stops and future work begins. They are cur - **Uncataloged models need an unambiguous protocol** — catalog routes preserve each model's protocol, including both Azure APIs under `azure`. A model absent from a mixed-protocol catalog needs an explicit route-level `api`, which overrides every model on the route and must be one of the supported custom protocols. For Azure deployment names, keep the catalog model ID and set `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. - **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. pi-ai's Gemini implementation refuses a request without an API key, so a `google-generative-ai` route always needs `apiKeyEnv`, whose value it sends as `x-goog-api-key`. -- **Gemini reasoning levels follow pi-ai's Gemini mapping** — on `google-generative-ai`, each `reasoningEfforts` value must resolve to `minimal`, `low`, `medium`, or `high`, or the request fails. Gemini 3 and later and `*-latest` model ids receive it as `thinkingLevel`; other ids receive a thinking-token budget chosen from the model id or `thinkingBudgets`. `off` sends a zero thinking budget to those other ids, which models that cannot disable thinking, such as Gemini 2.5 Pro, reject. +- **Gemini accepts four reasoning levels** — on `google-generative-ai`, pi-ai's Gemini implementation sends only `minimal`, `low`, `medium`, or `high`, compared without case, and fails a request whose `reasoningEfforts` value names anything else; the [Models page](../../client/ui-settings-models/README.md) offers only these levels for such a route. A request without a level, including `off`, sends pi-ai's disabled-thinking configuration, which models that cannot disable thinking, such as Gemini 2.5 Pro, may reject. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers. - **Conversation update support comes only from the installed pi-ai catalog** — native system and tool updates require the catalog model’s mid-conversation compat flags, which profiles cannot set. Hand-declared routes and unflagged models receive later system text as user-role reminders and the complete current tool list at the start of each request. - **Images are accepted only in user and tool-result messages** — images in system, developer, or assistant history fail with `UNSUPPORTED_CONTENT` on both conversion paths. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a5ff63c243..a8ee6f2eb6 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -267,7 +267,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **目录外模型需要明确的协议**——目录路由保留每个模型的协议,包括 `azure` 下的两种 Azure API。混合协议目录中不存在的模型需要显式设置路由级 `api`;该设置覆盖路由上的所有模型,且必须属于受支持的自定义协议。Azure 部署名称应通过 `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` 设置,模型 ID 保持为目录中的 ID。 - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 - **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。pi-ai 的 Gemini 实现会拒绝没有 API 密钥的请求,因此 `google-generative-ai` 路由始终需要 `apiKeyEnv`,其值以 `x-goog-api-key` 发送。 -- **Gemini 推理等级遵循 pi-ai 的 Gemini 映射**——在 `google-generative-ai` 上,每个 `reasoningEfforts` 值必须解析为 `minimal`、`low`、`medium` 或 `high`,否则请求失败。Gemini 3 及更新版本与 `*-latest` 模型 id 以 `thinkingLevel` 接收该值;其他 id 接收按模型 id 或 `thinkingBudgets` 选出的思考 token 预算。`off` 会向这些其他 id 发送零思考预算,无法关闭思考的模型(例如 Gemini 2.5 Pro)会拒绝该请求。 +- **Gemini 只接受四个推理等级**——在 `google-generative-ai` 上,pi-ai 的 Gemini 实现只发送 `minimal`、`low`、`medium` 或 `high`(不区分大小写),`reasoningEfforts` 的值为其他名称时请求失败;[模型页](../../client/ui-settings-models/README.zh.md)只为此类路由提供这些档位。不带等级的请求(包括 `off`)会发送 pi-ai 的关闭思考配置,无法关闭思考的模型(例如 Gemini 2.5 Pro)可能拒绝该请求。 - **不支持 `GenerateOptions.stop`**——pi-ai 的通用流式选项无法跨提供方保证停止序列行为。 - **对话更新支持只来自已安装的 pi-ai 目录**——原生系统与工具更新需要目录模型的对话中 compat 标志,profile 不能设置这些标志。手工声明的路由与未标记的模型会以用户角色提醒接收后续系统文本,并在每次请求开头接收完整的当前工具列表。 - **图片只在 user 与工具结果消息中被接受**——system、developer 或 assistant 历史中的图片在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index 22266a1005..020861bc94 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -37,6 +37,8 @@ import { catalogModels } from './catalog.ts' * requires an `api-version` query — and Codex authenticates through OAuth; * guessing at either would report an authentication failure as a provider * with no models. pi-ai's remaining protocols are absent for the same reason. + * The route protocol table in `provider.ts` may name a protocol absent here; + * discovery reports it as unsupported. */ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ 'anthropic-messages', diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index af6edea801..ae0b04c901 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -56,8 +56,7 @@ const PROTOCOLS: Readonly ProviderStreams>> = { * Every wire protocol a configured route may name, most-reached first. The * order is the table's and therefore stable; a configuration surface offering * a choice presents the first as its default, which is why the protocol a - * hand-declared gateway most often speaks — and the one endpoint interrogation - * can read — leads. + * hand-declared gateway most often speaks leads. * @returns the supported protocol identifiers. */ export function supportedProtocols(): readonly string[] {