mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
Merge main into worktree-20260803001-qa-reactor (provider presets, base URL help)
One conflict, same structural cause as the last merge: git offers the pre-split
Settings.tsx against the 183-line shell. All four of main's hunks belong to
ProviderFormModal, which now lives in settings/ProviderSettings.tsx — the useId
import, the addToast on a successful model fetch, and the base-URL field growing
an explicit label plus a help tooltip. Ported there; tsc caught the one import
(useUIStore) the move needed.
Everything else auto-merged, including the two files both sides changed:
chatStore.ts keeps main's pushAssistantHistoryThinking alongside the removal of
the content-equality replay guard — different functions, history mapping versus
the live path — and the five locales land at 2521 keys each.
Note: six generalSettings tests fail after this merge and they fail identically
on main. 62f648cb2 added an IconButton labelled 'Base URL help' next to the base
URL input without updating generalSettings.test.tsx, so getByLabelText(/Base
URL/i) now matches both. Confirmed against a temporary worktree at main: 6
failed / 104 passed there too. Not introduced here, and fixed separately.
This commit is contained in:
@@ -83,6 +83,26 @@ This project is maintained in the author's spare time. Corporate or individual s
|
||||
Thanks to <a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">XuanShu API</a> for sponsoring this project. XuanShu API is an all-in-one, enterprise-grade AI gateway that gives you access to leading global models through a single unified API, and works out of the box with popular AI coding tools such as Claude Code, Codex, and Gemini CLI. Stability comes first: multi-account pool scheduling and automatic failover smooth out upstream account fluctuations. Model calls are priced as low as 25% of official rates, and balance top-ups get roughly a 20% bonus with no expiry. New users who register through <a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">this link</a> receive a $5 credit.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://api.fenno.ai/s/WD8c">
|
||||
<img src="docs/images/sponsors/fennoai-logo.svg" width="180" alt="FennoAI">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://api.fenno.ai/s/WD8c">FennoAI</a> for sponsoring this project. FennoAI is a high-stability, high-performance API relay service for enterprise engineering teams and individual developers. It speaks both the OpenAI and Anthropic protocols, so it drops straight into Codex, Claude Code, OpenCode, and other mainstream AI coding tools. The platform is built for enterprise-grade stability at hundreds of billions of tokens per day, and supports corporate settlement and invoicing for both domestic and overseas entities. As a cc-haha user perk, buying a subscription through <a href="https://api.fenno.ai/s/WD8c">this link</a> gets you $50 of Coding Plan credit for $1.99. Referrals earn up to 20% commission — the more you invite, the more you get.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://s.qiniu.com/IZbyya">
|
||||
<img src="docs/images/sponsors/qiniuai-logo.svg" width="180" alt="Qiniu Cloud AI">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://s.qiniu.com/IZbyya">Qiniu Cloud AI</a> for sponsoring this project. Qiniu Cloud AI is the enterprise-grade LLM MaaS platform from Qiniu Cloud (02567.HK): one entry point to 150+ leading models worldwide, compatible with the major vendors' protocols, covering text, image, audio, video, and file processing across every modality, and serving more than 1.69 million enterprises and developers. As a cc-haha user perk, registering through <a href="https://s.qiniu.com/IZbyya">this link</a> gets enterprise users 12M free tokens and developers 3M free tokens.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -205,10 +225,6 @@ Thanks to the following open-source projects and community practices for referen
|
||||
|
||||
If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
<a href="https://www.repostars.dev/?repos=NanmiCoder%2Fcc-haha&theme=ocean">
|
||||
<img alt="Star History Chart" src="https://www.repostars.dev/api/embed?repo=NanmiCoder%2Fcc-haha&theme=ocean" />
|
||||
</a>
|
||||
|
||||
+22
-6
@@ -83,6 +83,26 @@ Claude Code Haha 是一个**桌面端 Claude Code 工作台**:把会话、多
|
||||
感谢 <a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">玄枢API</a> 赞助本项目!玄枢API 是一站式企业级 AI 网关,通过一个统一 API 即可接入 Claude Code、Codex、Gemini CLI 等热门 AI 编程工具,调用全球顶尖模型。平台以稳定为前提,通过多账号池调度与自动故障切换屏蔽上游账号波动;模型调用低至官方定价 2.5 折,余额充值再享约 8 折优惠且不设有效期。新用户通过 <a href="https://www.xuanshuapi.com/register?aff=CC-HAHA&promo=CC-HAHA">专属链接</a> 注册即赠 5 美元额度。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://api.fenno.ai/s/WD8c">
|
||||
<img src="docs/images/sponsors/fennoai-logo.svg" width="180" alt="FennoAI">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://api.fenno.ai/s/WD8c">FennoAI</a> 赞助本项目!FennoAI 是一家面向企业研发团队和开发者的高稳定、高性能 API 中转服务商,兼容 OpenAI 与 Anthropic 协议,可无缝接入 Codex、Claude Code、OpenCode 等主流 AI 编程工具。平台具备企业级稳定性,可支撑千亿 Token/日的调用规模,并支持境内外主体公对公结算及开票,满足企业级研发与采购需求。作为 cc-haha 用户专属福利,通过 <a href="https://api.fenno.ai/s/WD8c">专属链接</a> 购买订阅,仅需 1.99 美元即可获得价值 50 美元的 Coding Plan 额度;同时支持邀请奖励,邀请好友购买最高可获得 20% 返佣,邀请越多,奖励越高。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://s.qiniu.com/IZbyya">
|
||||
<img src="docs/images/sponsors/qiniuai-logo.svg" width="180" alt="七牛云 AI">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
感谢 <a href="https://s.qiniu.com/IZbyya">七牛云 AI</a> 赞助本项目!七牛云 AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过 169 万企业及开发者用户。作为 cc-haha 用户专属福利,通过 <a href="https://s.qiniu.com/IZbyya">专属链接</a> 注册,企业用户免费领 1200 万 Token,开发者免费领 300 万 Token。
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -205,10 +225,6 @@ cp .env.example .env
|
||||
|
||||
如果这个项目对你有帮助,欢迎点一个 ⭐ Star,让更多人发现 Claude Code Haha。
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
<a href="https://www.repostars.dev/?repos=NanmiCoder%2Fcc-haha&theme=ocean">
|
||||
<img alt="Star History Chart" src="https://www.repostars.dev/api/embed?repo=NanmiCoder%2Fcc-haha&theme=ocean" />
|
||||
</a>
|
||||
|
||||
@@ -1834,7 +1834,7 @@ describe('Settings > Providers tab', () => {
|
||||
expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1')
|
||||
})
|
||||
|
||||
it('keeps custom provider creation available when presets are unavailable', () => {
|
||||
it('keeps custom provider creation available when presets are unavailable', async () => {
|
||||
providerStoreState.presets = []
|
||||
|
||||
render(<Settings />)
|
||||
@@ -1846,7 +1846,11 @@ describe('Settings > Providers tab', () => {
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByLabelText(/Name/i)).toHaveValue('Custom')
|
||||
expect(within(dialog).getByLabelText(/Base URL/i)).toBeEnabled()
|
||||
expect(within(dialog).getByRole('textbox', { name: /Base URL/i })).toBeEnabled()
|
||||
|
||||
const baseUrlInfo = within(dialog).getByRole('button', { name: 'Base URL help' })
|
||||
fireEvent.mouseEnter(baseUrlInfo)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Enter the endpoint before /v1. The remaining path is added automatically.')
|
||||
})
|
||||
|
||||
it.each(['resolves', 'rejects'] as const)(
|
||||
@@ -2589,6 +2593,41 @@ describe('Settings > Providers tab', () => {
|
||||
expect(noMatches).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('toasts that the fetched list is now pickable from the dropdown', async () => {
|
||||
const dialog = await openProviderFormWithModels({
|
||||
ok: true,
|
||||
endpoint: 'https://api.example.com/v1/models',
|
||||
models: [
|
||||
{ id: 'gpt-5-mini', ownedBy: 'openai' },
|
||||
{ id: 'claude-sonnet-4-6', ownedBy: 'anthropic' },
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
|
||||
type: 'success',
|
||||
message: 'Model list loaded. Pick a model from the dropdown now.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not toast when the fetched model list is empty', async () => {
|
||||
const dialog = await openProviderFormWithModels({
|
||||
ok: true,
|
||||
endpoint: 'https://api.example.com/v1/models',
|
||||
models: [],
|
||||
})
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Fetch models|获取模型/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(providerStoreState.fetchModels).toHaveBeenCalled()
|
||||
})
|
||||
expect(useUIStore.getState().toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes a picked model through the shared model change handler', async () => {
|
||||
const dialog = await openProviderFormWithModels({
|
||||
ok: true,
|
||||
|
||||
@@ -8,6 +8,8 @@ const viewportMocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
|
||||
const originalRangeGetClientRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
|
||||
const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
@@ -239,8 +241,29 @@ describe('ChatInput file mentions', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
// jsdom does not implement it, and the branch list scrolls its active row
|
||||
// into view whenever the selection moves.
|
||||
// jsdom does not implement these layout APIs. ProseMirror reads Range
|
||||
// geometry when a newline transaction scrolls the new selection into view.
|
||||
Object.defineProperties(Range.prototype, {
|
||||
getClientRects: {
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
},
|
||||
getBoundingClientRect: {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
// The branch list also scrolls its active row whenever the selection moves.
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 })
|
||||
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||
@@ -260,6 +283,16 @@ describe('ChatInput file mentions', () => {
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'offsetWidth')
|
||||
}
|
||||
if (originalRangeGetClientRects) {
|
||||
Object.defineProperty(Range.prototype, 'getClientRects', originalRangeGetClientRects)
|
||||
} else {
|
||||
Reflect.deleteProperty(Range.prototype, 'getClientRects')
|
||||
}
|
||||
if (originalRangeGetBoundingClientRect) {
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalRangeGetBoundingClientRect)
|
||||
} else {
|
||||
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
|
||||
}
|
||||
})
|
||||
|
||||
// jsdom lays nothing out, so the composer column's width has to be stated.
|
||||
@@ -1935,7 +1968,37 @@ describe('ChatInput file mentions', () => {
|
||||
expect(screen.getByTestId('chat-input-toolbar')).toHaveClass('-mx-3')
|
||||
})
|
||||
|
||||
it('uses Ctrl or Command Enter to send when that composer preference is selected', async () => {
|
||||
it('uses Shift+Enter for a newline when Enter is the configured send shortcut', async () => {
|
||||
useSettingsStore.setState({
|
||||
chatSendBehavior: 'enter',
|
||||
})
|
||||
|
||||
render(<ChatInput />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId)
|
||||
})
|
||||
|
||||
const input = getComposerElement()
|
||||
setComposerText('firstsecond', 5)
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
|
||||
|
||||
expect(mocks.wsSend).not.toHaveBeenCalled()
|
||||
expect(getComposerText()).toBe('first\nsecond')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: 'first\nsecond',
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Ctrl+Enter', { ctrlKey: true }],
|
||||
['Command+Enter', { metaKey: true }],
|
||||
])('uses Enter or Shift+Enter for newlines and %s to send when configured', async (_shortcut, modifier) => {
|
||||
useSettingsStore.setState({
|
||||
chatSendBehavior: 'modifierEnter',
|
||||
})
|
||||
@@ -1947,15 +2010,20 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
|
||||
const input = getComposerElement()
|
||||
setComposerText('avoid accidental sends', 'avoid accidental sends'.length)
|
||||
setComposerText('firstsecond', 5)
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(mocks.wsSend).not.toHaveBeenCalled()
|
||||
expect(getComposerText()).toBe('first\nsecond')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true })
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
|
||||
expect(mocks.wsSend).not.toHaveBeenCalled()
|
||||
expect(getComposerText()).toBe('first\n\nsecond')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter', ...modifier })
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: 'avoid accidental sends',
|
||||
content: 'first\n\nsecond',
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { keymap } from 'prosemirror-keymap'
|
||||
import { history, redo, undo } from 'prosemirror-history'
|
||||
import { baseKeymap } from 'prosemirror-commands'
|
||||
import { baseKeymap, splitBlock } from 'prosemirror-commands'
|
||||
import {
|
||||
buildComposerDoc,
|
||||
composerSchema,
|
||||
@@ -139,6 +139,10 @@ export const MentionComposer = forwardRef<MentionComposerHandle, MentionComposer
|
||||
keymap({
|
||||
'Backspace': deleteAdjacentMentionAtom('backward'),
|
||||
'Delete': deleteAdjacentMentionAtom('forward'),
|
||||
// Unlike a textarea, ProseMirror does not include Shift+Enter in
|
||||
// its base keymap. Keep the composer's configured newline path
|
||||
// explicit instead of relying on browser contenteditable behavior.
|
||||
'Shift-Enter': splitBlock,
|
||||
}),
|
||||
keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo }),
|
||||
keymap(baseKeymap),
|
||||
|
||||
@@ -596,6 +596,32 @@ describe('TabBar', () => {
|
||||
expect(screen.queryByText('设置')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches only the settings tab to the settings rail width', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { SETTINGS_TAB_ID, useTabStore } = await import('../../stores/tabStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' },
|
||||
{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' },
|
||||
],
|
||||
activeTabId: SETTINGS_TAB_ID,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
const settingsTab = screen.getByText('Localized Settings').closest('.tab-strip-item')
|
||||
const chatTab = screen.getByText('Chat').closest('.tab-strip-item')
|
||||
|
||||
expect(settingsTab?.className).toContain('min-w-[195px]')
|
||||
expect(settingsTab?.className).toContain('max-w-[195px]')
|
||||
expect(chatTab?.className).toContain('min-w-[140px]')
|
||||
expect(chatTab?.className).toContain('max-w-[200px]')
|
||||
expect(chatTab?.className).not.toContain('min-w-[195px]')
|
||||
})
|
||||
|
||||
it('shows current-session CLI tasks without a numeric activity badge', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
|
||||
@@ -751,7 +751,8 @@ const TabItem = forwardRef<HTMLDivElement, {
|
||||
onMouseDown={onMouseDown}
|
||||
onContextMenu={onContextMenu}
|
||||
className={`
|
||||
tab-bar-interactive tab-strip-item group relative flex min-h-[46px] min-w-[140px] max-w-[200px] flex-shrink-0 items-center rounded-t-[8px] border border-b-0 px-3
|
||||
tab-bar-interactive tab-strip-item group relative flex min-h-[46px] flex-shrink-0 items-center rounded-t-[8px] border border-b-0 px-3
|
||||
${tab.type === 'settings' ? 'min-w-[195px] max-w-[195px]' : 'min-w-[140px] max-w-[200px]'}
|
||||
${isDragging ? 'z-[var(--z-sticky)] cursor-grabbing' : 'cursor-grab'}
|
||||
transition-[background-color,border-color,box-shadow,opacity,transform] duration-150 ease-out
|
||||
${isActive || isDragging
|
||||
|
||||
@@ -90,9 +90,32 @@ describe('bundled provider presets', () => {
|
||||
expect(selectableIds).not.toContain('shengsuanyun')
|
||||
expect(selectableIds).toContain('teamorouter')
|
||||
expect(selectableIds).toContain('xuanshuapi')
|
||||
expect(selectableIds).toContain('fennoai')
|
||||
expect(selectableIds).toContain('qiniuai')
|
||||
expect(selectableIds).toContain('custom')
|
||||
})
|
||||
|
||||
// Both gateways mount the Anthropic protocol on the bare host; a /v1 or
|
||||
// /anthropic suffix here would double up with the path Claude Code appends.
|
||||
it('points the sponsored gateways at their Anthropic-compatible roots', () => {
|
||||
const fennoai = BUNDLED_PROVIDER_PRESETS.find((preset) => preset.id === 'fennoai')
|
||||
const qiniuai = BUNDLED_PROVIDER_PRESETS.find((preset) => preset.id === 'qiniuai')
|
||||
|
||||
expect(fennoai?.baseUrl).toBe('https://api.fenno.ai')
|
||||
expect(fennoai && presetMatchesBaseUrl(fennoai, ' HTTPS://API.Fenno.AI/ ')).toBe(true)
|
||||
expect(qiniuai?.baseUrl).toBe('https://api.qnaigc.com')
|
||||
expect(qiniuai && presetMatchesBaseUrl(qiniuai, ' HTTPS://API.QNAIGC.COM/ ')).toBe(true)
|
||||
})
|
||||
|
||||
// The picker groups featured presets into their own row; losing the flag would
|
||||
// silently demote the sponsors into the generic list.
|
||||
it('keeps the sponsored gateways in the featured row', () => {
|
||||
const featuredIds = BUNDLED_PROVIDER_PRESETS.filter((preset) => preset.featured).map((p) => p.id)
|
||||
|
||||
expect(featuredIds).toContain('fennoai')
|
||||
expect(featuredIds).toContain('qiniuai')
|
||||
})
|
||||
|
||||
it('keeps the retired 接口AI preset resolvable but not selectable', () => {
|
||||
const jiekouai = BUNDLED_PROVIDER_PRESETS.find((preset) => preset.id === 'jiekouai')
|
||||
|
||||
|
||||
@@ -690,6 +690,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'settings.providers.notesPlaceholder': 'Optional notes...',
|
||||
'settings.providers.baseUrl': 'Base URL',
|
||||
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
|
||||
'settings.providers.baseUrlHelp': 'Base URL help',
|
||||
'settings.providers.baseUrlTooltip': 'Enter the endpoint before /v1. The remaining path is added automatically.',
|
||||
'settings.providers.endpointRegion': 'Service region',
|
||||
'settings.providers.regionChina': 'China mainland',
|
||||
'settings.providers.regionGlobal': 'Global',
|
||||
@@ -760,6 +762,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'settings.providers.fetchModelsApiKeyHint': 'Enter an API key to fetch the model list.',
|
||||
'settings.providers.fetchModelsSupportHint': 'This works only when the upstream provider publishes a model list endpoint.',
|
||||
'settings.providers.fetchModelsLoaded': 'Model list loaded ({count}). Pick one or keep typing the ID.',
|
||||
'settings.providers.fetchModelsToast': 'Model list loaded. Pick a model from the dropdown now.',
|
||||
'settings.providers.fetchModelsEmpty': 'No models found. Enter the model ID manually.',
|
||||
'settings.providers.fetchModelsPick': 'Choose {label} from the fetched list',
|
||||
'settings.providers.fetchModelsGroupOther': 'Other',
|
||||
|
||||
@@ -692,6 +692,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.providers.notesPlaceholder': '任意のメモ...',
|
||||
'settings.providers.baseUrl': 'ベース URL',
|
||||
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
|
||||
'settings.providers.baseUrlHelp': 'ベース URL の入力説明',
|
||||
'settings.providers.baseUrlTooltip': '/v1 より前のエンドポイントを入力してください。後続のパスは自動的に補完されます。',
|
||||
'settings.providers.endpointRegion': 'サービス地域',
|
||||
'settings.providers.regionChina': '中国本土',
|
||||
'settings.providers.regionGlobal': 'グローバル',
|
||||
@@ -762,6 +764,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.providers.fetchModelsApiKeyHint': 'モデル一覧を取得するには API キーを入力してください。',
|
||||
'settings.providers.fetchModelsSupportHint': '上流プロバイダーがモデル一覧エンドポイントを公開している場合のみ取得できます。',
|
||||
'settings.providers.fetchModelsLoaded': 'モデル一覧を取得しました({count} 件)。選択しても、手入力を続けても構いません。',
|
||||
'settings.providers.fetchModelsToast': 'モデル一覧を取得しました。ドロップダウンからモデルを選択できます。',
|
||||
'settings.providers.fetchModelsEmpty': 'モデルが見つかりません。モデル ID を手入力してください。',
|
||||
'settings.providers.fetchModelsPick': '取得したリストから{label}を選択',
|
||||
'settings.providers.fetchModelsGroupOther': 'その他',
|
||||
|
||||
@@ -692,6 +692,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.providers.notesPlaceholder': '선택 사항 메모...',
|
||||
'settings.providers.baseUrl': '기본 URL',
|
||||
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
|
||||
'settings.providers.baseUrlHelp': '기본 URL 입력 도움말',
|
||||
'settings.providers.baseUrlTooltip': '/v1 앞까지의 엔드포인트를 입력하세요. 이후 경로는 자동으로 추가됩니다.',
|
||||
'settings.providers.endpointRegion': '서비스 지역',
|
||||
'settings.providers.regionChina': '중국 본토',
|
||||
'settings.providers.regionGlobal': '글로벌',
|
||||
@@ -762,6 +764,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.providers.fetchModelsApiKeyHint': '모델 목록을 가져오려면 API 키를 입력하세요.',
|
||||
'settings.providers.fetchModelsSupportHint': '상위 공급자가 모델 목록 엔드포인트를 제공하는 경우에만 가져올 수 있습니다.',
|
||||
'settings.providers.fetchModelsLoaded': '모델 목록을 가져왔습니다({count}개). 선택하거나 직접 입력해도 됩니다.',
|
||||
'settings.providers.fetchModelsToast': '모델 목록을 가져왔습니다. 이제 드롭다운에서 모델을 선택할 수 있습니다.',
|
||||
'settings.providers.fetchModelsEmpty': '모델을 찾지 못했습니다. 모델 ID를 직접 입력하세요.',
|
||||
'settings.providers.fetchModelsPick': '가져온 목록에서 {label} 선택',
|
||||
'settings.providers.fetchModelsGroupOther': '기타',
|
||||
|
||||
@@ -691,6 +691,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.notesPlaceholder': '可選備註...',
|
||||
'settings.providers.baseUrl': '介面地址',
|
||||
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
|
||||
'settings.providers.baseUrlHelp': '介面地址填寫說明',
|
||||
'settings.providers.baseUrlTooltip': '請填寫 /v1 之前的介面地址,系統會自動補全後續路徑。',
|
||||
'settings.providers.endpointRegion': '服務區域',
|
||||
'settings.providers.regionChina': '中國大陸',
|
||||
'settings.providers.regionGlobal': '海外',
|
||||
@@ -761,6 +763,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.fetchModelsApiKeyHint': '要填寫 API Key 才能取得模型清單',
|
||||
'settings.providers.fetchModelsSupportHint': '僅當上游服務商提供模型清單端點時才能取得。',
|
||||
'settings.providers.fetchModelsLoaded': '已取得模型清單({count} 個),可直接選擇,也可繼續手動輸入。',
|
||||
'settings.providers.fetchModelsToast': '已取得模型清單,現在可以在下拉框中選擇模型了。',
|
||||
'settings.providers.fetchModelsEmpty': '沒有找到模型,請手動填寫模型 ID。',
|
||||
'settings.providers.fetchModelsPick': '從已取得的清單中選擇{label}',
|
||||
'settings.providers.fetchModelsGroupOther': '其他',
|
||||
|
||||
@@ -691,6 +691,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.notesPlaceholder': '可选备注...',
|
||||
'settings.providers.baseUrl': '接口地址',
|
||||
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
|
||||
'settings.providers.baseUrlHelp': '接口地址填写说明',
|
||||
'settings.providers.baseUrlTooltip': '请填写 /v1 之前的接口地址,系统会自动补全后续路径。',
|
||||
'settings.providers.endpointRegion': '服务区域',
|
||||
'settings.providers.regionChina': '中国大陆',
|
||||
'settings.providers.regionGlobal': '海外',
|
||||
@@ -761,6 +763,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.fetchModelsApiKeyHint': '要填写 API Key 才能获取模型列表',
|
||||
'settings.providers.fetchModelsSupportHint': '仅当上游服务商提供模型列表接口时才能获取。',
|
||||
'settings.providers.fetchModelsLoaded': '已获取模型列表({count} 个),可直接选择,也可继续手动输入。',
|
||||
'settings.providers.fetchModelsToast': '已获取模型列表,现在可以在下拉框中选择模型了。',
|
||||
'settings.providers.fetchModelsEmpty': '没有找到模型,请手动填写模型 ID。',
|
||||
'settings.providers.fetchModelsPick': '从已获取的列表中选择{label}',
|
||||
'settings.providers.fetchModelsGroupOther': '其他',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef, useId, type CSSProperties, type ReactNode } from 'react'
|
||||
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useProviderStore } from '../../stores/providerStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
@@ -960,7 +961,9 @@ function openExternalUrl(url: string) {
|
||||
function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) {
|
||||
const { createProvider, updateProvider, testConfig, fetchModels } = useProviderStore()
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const t = useTranslation()
|
||||
const baseUrlInputId = useId()
|
||||
|
||||
const fallbackPreset = buildFallbackPreset(provider)
|
||||
const loadedPresets = presets.filter((p) => p.id !== 'official')
|
||||
@@ -1343,6 +1346,14 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
if (modelsRequestRef.current !== requestId) return
|
||||
if (result.ok) {
|
||||
setFetchedModels(result.models)
|
||||
// The picker that just appeared is easy to miss below the button, so
|
||||
// point the user at it. Empty lists already render an inline notice.
|
||||
if (result.models.length > 0) {
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: t('settings.providers.fetchModelsToast'),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setFetchedModels(null)
|
||||
setModelsErrorCode(result.errorCode)
|
||||
@@ -1545,7 +1556,25 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} className="font-mono text-[13px]" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label htmlFor={baseUrlInputId} className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.providers.baseUrl')}
|
||||
<span className="ml-0.5 text-[var(--color-error)]">*</span>
|
||||
</label>
|
||||
<Tooltip content={t('settings.providers.baseUrlTooltip')} placement="bottom-start">
|
||||
<IconButton
|
||||
icon="info"
|
||||
label={t('settings.providers.baseUrlHelp')}
|
||||
showTooltip={false}
|
||||
size="2xs"
|
||||
tone="muted"
|
||||
shape="circle"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input id={baseUrlInputId} required value={baseUrl} onChange={(e) => handleBaseUrlChange(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} className="font-mono text-[13px]" />
|
||||
</div>
|
||||
|
||||
{/* API Format */}
|
||||
{(isCustom || mode === 'edit') ? (
|
||||
|
||||
@@ -484,6 +484,69 @@ describe('chatStore history mapping', () => {
|
||||
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
|
||||
})
|
||||
|
||||
it('collapses replayed and blank thinking blocks from history mapping', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'assistant-snap-1',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:00.000Z',
|
||||
content: [{ type: 'thinking', thinking: 'plan the fix' }],
|
||||
},
|
||||
// 流式快照整块重发:逐字相同,应丢弃
|
||||
{
|
||||
id: 'assistant-snap-2',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:01.000Z',
|
||||
content: [{ type: 'thinking', thinking: 'plan the fix' }],
|
||||
},
|
||||
// 流式快照前缀增长:应替换为更全的新块而非追加
|
||||
{
|
||||
id: 'assistant-snap-3',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:02.000Z',
|
||||
content: [{ type: 'thinking', thinking: 'plan the fix carefully' }],
|
||||
},
|
||||
// 纯空白块:不应产生空壳气泡
|
||||
{
|
||||
id: 'assistant-blank',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:03.000Z',
|
||||
content: [{ type: 'thinking', thinking: ' \n ' }],
|
||||
},
|
||||
// 相邻但内容无关的思考:合并为一个气泡
|
||||
{
|
||||
id: 'assistant-more',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:04.000Z',
|
||||
content: [{ type: 'thinking', thinking: 'then run tests' }],
|
||||
},
|
||||
// 被工具打断后的新思考:保持独立
|
||||
{
|
||||
id: 'assistant-tools',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:05.000Z',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', id: 'bash-1', input: { command: 'pwd' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'assistant-final',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-06T00:00:06.000Z',
|
||||
content: [{ type: 'thinking', thinking: 'tests pass' }],
|
||||
},
|
||||
]
|
||||
|
||||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||||
|
||||
expect(mapped.map((message) => message.type)).toEqual(['thinking', 'tool_use', 'thinking'])
|
||||
expect(mapped[0]).toMatchObject({
|
||||
id: 'assistant-snap-1-block-0',
|
||||
content: 'plan the fix carefullythen run tests',
|
||||
})
|
||||
expect(mapped[2]).toMatchObject({ id: 'assistant-final-block-0', content: 'tests pass' })
|
||||
})
|
||||
|
||||
it('maps AskUserQuestion transcript answers from toolUseResult metadata', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
|
||||
@@ -4149,6 +4149,32 @@ function pushAssistantHistoryText(
|
||||
})
|
||||
}
|
||||
|
||||
function pushAssistantHistoryThinking(
|
||||
messages: UIMessage[],
|
||||
id: string,
|
||||
content: string,
|
||||
timestamp: number,
|
||||
): void {
|
||||
// 与流式路径(case 'thinking')保持同等防护:纯空白块不产生空壳气泡。
|
||||
if (!content.trim()) return
|
||||
|
||||
const last = messages[messages.length - 1]
|
||||
if (last?.type === 'thinking') {
|
||||
// 流式落盘的快照会让同一段思考在 jsonl 里以"整块重发"或"前缀增长"的
|
||||
// 形态重复出现,逐字相同直接丢弃,前缀包含则用更全的新块替换旧块。
|
||||
// 合并时保留首个块的确定性 id,保证轮询重映射时 React key 稳定。
|
||||
if (last.content === content) return
|
||||
if (content.startsWith(last.content)) {
|
||||
last.content = content
|
||||
return
|
||||
}
|
||||
last.content += content
|
||||
return
|
||||
}
|
||||
|
||||
messages.push({ id, type: 'thinking', content, timestamp })
|
||||
}
|
||||
|
||||
type HistoryMappingOptions = {
|
||||
includeTeammateMessages?: boolean
|
||||
}
|
||||
@@ -4766,7 +4792,7 @@ export function mapHistoryMessagesToUiMessages(
|
||||
}
|
||||
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
|
||||
for (const [blockIndex, block] of (msg.content as AssistantHistoryBlock[]).entries()) {
|
||||
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: `${msg.id}-block-${blockIndex}`, type: 'thinking', content: block.thinking, timestamp })
|
||||
if (block.type === 'thinking' && block.thinking) pushAssistantHistoryThinking(uiMessages, `${msg.id}-block-${blockIndex}`, block.thinking, timestamp)
|
||||
else if (block.type === 'text' && block.text) {
|
||||
pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model, msg.id || undefined)
|
||||
}
|
||||
|
||||
@@ -44,10 +44,13 @@ With an API key in hand, this is the fastest route. Click "Add Provider", pick s
|
||||
The built-in presets, as they appear in the dialog:
|
||||
|
||||
- **DeepSeek** · **Zhipu GLM** · **Kimi** · **MiniMax** — major Chinese model vendors; the base URLs point at each one's Anthropic-compatible endpoint.
|
||||
- **TeamoRouter** · **XuanShu API** — routing services that give you access to official Claude models through their own gateways.
|
||||
- **TeamoRouter** · **XuanShu API** · **FennoAI** — routing services that give you access to official Claude models through their own gateways.
|
||||
- **Qiniu Cloud AI** — an aggregator MaaS platform: one key for DeepSeek, GLM, Kimi, Qwen, and more.
|
||||
- **LM Studio** · **Ollama** — local models; see the next section.
|
||||
- **Custom** — anything not listed above.
|
||||
|
||||
Which models FennoAI and Qiniu Cloud AI give you depends on the plan you bought, so those two presets fill in the base URL only and pin no models: paste your key, hit "Fetch models", and pick one from the live list.
|
||||
|
||||
When a preset has a signup page for API keys, a "Get API Key" button appears under the key field.
|
||||
|
||||
## Local models
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.9 KiB |
@@ -44,10 +44,13 @@ Claude Code Haha 自己不带模型,它只是那个替你干活的壳。装完
|
||||
内置预设(按弹窗里的排列):
|
||||
|
||||
- **DeepSeek** · **Zhipu GLM** · **Kimi** · **MiniMax** — 国内主流模型厂商,接口地址是各家的 Anthropic 兼容端点。
|
||||
- **TeamoRouter** · **玄枢API** — 中转类服务商,用它们的通道调 Claude 官方模型。
|
||||
- **TeamoRouter** · **玄枢API** · **FennoAI** — 中转类服务商,用它们的通道调 Claude 官方模型。
|
||||
- **七牛云 AI** — 聚合类 MaaS 平台,一个密钥调 DeepSeek、GLM、Kimi、通义等多家模型。
|
||||
- **LM Studio** · **Ollama** — 本地模型,见下一节。
|
||||
- **Custom** — 上面都没有,自己填。
|
||||
|
||||
FennoAI 和七牛云 AI 能用哪些模型取决于你买的套餐,所以这两个预设只填接口地址、不预填模型:粘好密钥后点「拉取模型」,从实时列表里挑一个即可。
|
||||
|
||||
选中预设后,如果这家服务商有申请密钥的页面,密钥输入框下面会出现一颗「获取 API Key」按钮,点开就是注册/取密钥的地址。
|
||||
|
||||
## 本地模型
|
||||
|
||||
@@ -70,6 +70,8 @@ describe('provider presets API', () => {
|
||||
const shengsuanyun = PROVIDER_PRESETS.find((preset) => preset.id === 'shengsuanyun')
|
||||
const teamorouter = PROVIDER_PRESETS.find((preset) => preset.id === 'teamorouter')
|
||||
const xuanshuapi = PROVIDER_PRESETS.find((preset) => preset.id === 'xuanshuapi')
|
||||
const fennoai = PROVIDER_PRESETS.find((preset) => preset.id === 'fennoai')
|
||||
const qiniuai = PROVIDER_PRESETS.find((preset) => preset.id === 'qiniuai')
|
||||
|
||||
expect(lmstudio?.baseUrl).toBe('http://localhost:1234')
|
||||
expect(lmstudio?.apiFormat).toBe('anthropic')
|
||||
@@ -138,6 +140,27 @@ describe('provider presets API', () => {
|
||||
expect(xuanshuapi?.defaultModels.sonnet).toBe('claude-sonnet-5')
|
||||
expect(xuanshuapi?.defaultModels.opus).toBe('claude-opus-5')
|
||||
expect(xuanshuapi?.modelContextWindows?.['claude-opus-5']).toBe(1000000)
|
||||
// Both endpoints below are the Anthropic-compatible root: Claude Code appends
|
||||
// /v1/messages itself, so a /v1 or /anthropic suffix here would 404.
|
||||
expect(fennoai?.baseUrl).toBe('https://api.fenno.ai')
|
||||
expect(fennoai?.apiFormat).toBe('anthropic')
|
||||
expect(fennoai?.authStrategy).toBe('auth_token')
|
||||
expect(qiniuai?.baseUrl).toBe('https://api.qnaigc.com')
|
||||
expect(qiniuai?.apiFormat).toBe('anthropic')
|
||||
expect(qiniuai?.authStrategy).toBe('auth_token')
|
||||
// Both gateways front a catalog that shifts on their side, so the preset ships
|
||||
// no model ids: the user fetches the live list and picks. Pinning a default
|
||||
// here would hand out a model their plan may not even carry.
|
||||
for (const preset of [fennoai, qiniuai]) {
|
||||
expect(preset?.defaultModels).toEqual({ main: '', haiku: '', sonnet: '', opus: '' })
|
||||
// A pinned subagent model would reintroduce exactly that guess.
|
||||
expect(preset?.defaultEnv).toEqual({})
|
||||
}
|
||||
// The window table stays: it is keyed by model id, so it keeps paying off
|
||||
// once the user picks one — including ids the built-in table cannot resolve
|
||||
// (claude-opus-5, and every namespaced 七牛云 id).
|
||||
expect(fennoai?.modelContextWindows?.['claude-opus-5']).toBe(1000000)
|
||||
expect(qiniuai?.modelContextWindows?.['deepseek/deepseek-v4-pro']).toBe(1000000)
|
||||
})
|
||||
|
||||
test('configured presets can expose optional API key and promo metadata', () => {
|
||||
@@ -150,6 +173,8 @@ describe('provider presets API', () => {
|
||||
const shengsuanyun = PROVIDER_PRESETS.find((preset) => preset.id === 'shengsuanyun')
|
||||
const teamorouter = PROVIDER_PRESETS.find((preset) => preset.id === 'teamorouter')
|
||||
const xuanshuapi = PROVIDER_PRESETS.find((preset) => preset.id === 'xuanshuapi')
|
||||
const fennoai = PROVIDER_PRESETS.find((preset) => preset.id === 'fennoai')
|
||||
const qiniuai = PROVIDER_PRESETS.find((preset) => preset.id === 'qiniuai')
|
||||
const custom = PROVIDER_PRESETS.find((preset) => preset.id === 'custom')
|
||||
|
||||
expect(lmstudio?.needsApiKey).toBe(false)
|
||||
@@ -204,6 +229,17 @@ describe('provider presets API', () => {
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5',
|
||||
})
|
||||
expect(xuanshuapi?.modelContextWindows?.['claude-sonnet-5']).toBe(1000000)
|
||||
expect(fennoai?.apiKeyUrl).toBe('https://api.fenno.ai/s/WD8c')
|
||||
expect(fennoai?.promoText).toContain('1.99 美元')
|
||||
expect(fennoai?.featured).toBe(true)
|
||||
expect(fennoai?.modelContextWindows?.['claude-sonnet-5']).toBe(1000000)
|
||||
expect(fennoai?.modelContextWindows?.['claude-haiku-4-5']).toBe(200000)
|
||||
expect(qiniuai?.apiKeyUrl).toBe('https://s.qiniu.com/IZbyya')
|
||||
expect(qiniuai?.promoText).toContain('Token')
|
||||
expect(qiniuai?.featured).toBe(true)
|
||||
expect(qiniuai?.modelContextWindows?.['deepseek/deepseek-v4-flash']).toBe(1000000)
|
||||
expect(qiniuai?.modelContextWindows?.['z-ai/glm-5.2']).toBe(1000000)
|
||||
expect(qiniuai?.modelContextWindows?.['moonshotai/kimi-k3']).toBe(262144)
|
||||
expect(custom?.promoText).toBeUndefined()
|
||||
expect(custom?.authStrategy).toBe('auth_token')
|
||||
expect(custom?.defaultEnv).toBeUndefined()
|
||||
|
||||
@@ -236,6 +236,61 @@
|
||||
"claude-haiku-4-5-20251001": 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fennoai",
|
||||
"name": "FennoAI",
|
||||
"baseUrl": "https://api.fenno.ai",
|
||||
"apiFormat": "anthropic",
|
||||
"defaultModels": {
|
||||
"main": "",
|
||||
"haiku": "",
|
||||
"sonnet": "",
|
||||
"opus": ""
|
||||
},
|
||||
"needsApiKey": true,
|
||||
"websiteUrl": "https://api.fenno.ai",
|
||||
"apiKeyUrl": "https://api.fenno.ai/s/WD8c",
|
||||
"promoText": "FennoAI 为 cc-haha 的用户提供了专属福利,通过此链接购买订阅,仅需 1.99 美元即可获得价值 50 美元的 Coding Plan 额度!",
|
||||
"featured": true,
|
||||
"authStrategy": "auth_token",
|
||||
"defaultEnv": {},
|
||||
"modelContextWindows": {
|
||||
"claude-opus-5": 1000000,
|
||||
"claude-opus-4-8": 1000000,
|
||||
"claude-sonnet-5": 1000000,
|
||||
"claude-sonnet-4-6": 200000,
|
||||
"claude-haiku-4-5": 200000,
|
||||
"claude-haiku-4-5-20251001": 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "qiniuai",
|
||||
"name": "七牛云 AI",
|
||||
"baseUrl": "https://api.qnaigc.com",
|
||||
"apiFormat": "anthropic",
|
||||
"defaultModels": {
|
||||
"main": "",
|
||||
"haiku": "",
|
||||
"sonnet": "",
|
||||
"opus": ""
|
||||
},
|
||||
"needsApiKey": true,
|
||||
"websiteUrl": "https://www.qiniu.com/ai/models",
|
||||
"apiKeyUrl": "https://s.qiniu.com/IZbyya",
|
||||
"promoText": "七牛云 AI 为 cc-haha 的用户提供了专属福利,通过此链接注册,企业用户免费领 1200 万 Token,开发者免费领 300 万 Token!",
|
||||
"featured": true,
|
||||
"authStrategy": "auth_token",
|
||||
"defaultEnv": {},
|
||||
"modelContextWindows": {
|
||||
"deepseek/deepseek-v4-pro": 1000000,
|
||||
"deepseek/deepseek-v4-flash": 1000000,
|
||||
"z-ai/glm-5.2": 1000000,
|
||||
"z-ai/glm-5.1": 200000,
|
||||
"moonshotai/kimi-k3": 262144,
|
||||
"moonshotai/kimi-k2.7-code": 262144,
|
||||
"minimax/minimax-m3": 1000000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lmstudio",
|
||||
"name": "LM Studio",
|
||||
|
||||
Reference in New Issue
Block a user