diff --git a/.gitignore b/.gitignore index 6a07141..f70d8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ GoNavi-Wails.exe .claude/ .gemini/ **/tmpclaude-* +docs/superpowers/ +docs/需求追踪/ CLAUDE.md **/CLAUDE.md diff --git a/assets_dev.go b/assets_dev.go new file mode 100644 index 0000000..2d3caf3 --- /dev/null +++ b/assets_dev.go @@ -0,0 +1,9 @@ +//go:build dev + +package main + +import "os" + +// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS, +// 避免编译时依赖 frontend/dist 被并发重建。 +var assets = os.DirFS(".") diff --git a/assets_prod.go b/assets_prod.go new file mode 100644 index 0000000..6cf94d5 --- /dev/null +++ b/assets_prod.go @@ -0,0 +1,13 @@ +//go:build !dev + +package main + +import ( + "embed" + "io/fs" +) + +//go:embed all:frontend/dist +var embeddedAssets embed.FS + +var assets fs.FS = embeddedAssets diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3018db7..efbd2b6 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file +6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 457b836..00cbaaa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,13 @@ import { isShortcutMatch, normalizeShortcutCombo, } from './utils/shortcuts'; +import { + SIDEBAR_UTILITY_ITEM_KEYS, + resolveAIEntryPlacement, + resolveAIEdgeHandleAttachment, + resolveAIEdgeHandleDockStyle, + resolveAIEdgeHandleStyle, +} from './utils/aiEntryLayout'; import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import './App.css'; @@ -1125,6 +1132,61 @@ function App() { const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); const [isAISettingsOpen, setIsAISettingsOpen] = useState(false); + const aiEntryPlacement = resolveAIEntryPlacement(); + const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible); + const aiEdgeHandleDockStyle = useMemo( + () => resolveAIEdgeHandleDockStyle(aiEdgeHandleAttachment), + [aiEdgeHandleAttachment], + ); + const aiEdgeHandleStyle = useMemo(() => ( + resolveAIEdgeHandleStyle({ + darkMode, + aiPanelVisible, + effectiveUiScale, + }) + ), [aiPanelVisible, darkMode, effectiveUiScale]); + const sidebarUtilityItems = useMemo(() => { + const itemMap = { + tools: { + key: 'tools', + title: '工具', + icon: , + onClick: () => setIsToolsModalOpen(true), + }, + proxy: { + key: 'proxy', + title: '代理', + icon: , + onClick: () => setIsProxyModalOpen(true), + }, + theme: { + key: 'theme', + title: '主题', + icon: , + onClick: () => setIsThemeModalOpen(true), + }, + about: { + key: 'about', + title: '关于', + icon: , + onClick: () => setIsAboutOpen(true), + }, + } as const; + + return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]); + }, []); + const renderAIEdgeHandle = () => ( + + + + ); // Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制 @@ -1634,24 +1696,12 @@ function App() { >
-
-
@@ -1760,12 +1810,24 @@ function App() { /> -
+
+ {aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && ( +
+ {renderAIEdgeHandle()} +
+ )} {aiPanelVisible && ( - setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> +
+ {aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && ( +
+ {renderAIEdgeHandle()} +
+ )} + setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} /> +
)}
{isLogPanelOpen && ( diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index e148379..403f352 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -3,12 +3,10 @@ import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Ta import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types'; import { - getProviderFingerprint, - getProviderHostname, - matchQwenPresetKey, QWEN_BAILIAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_MODELS, + resolveProviderPresetKey, resolvePresetBaseURL, resolvePresetModelSelection, resolvePresetTransport, @@ -62,28 +60,9 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; -const matchProviderPreset = (provider: Pick): ProviderPreset => { - const qwenPresetKey = matchQwenPresetKey(provider); - if (qwenPresetKey) { - return findPreset(qwenPresetKey); - } - const fingerprint = getProviderFingerprint(provider.baseUrl); - const exactPreset = PROVIDER_PRESETS.find(pr => - pr.backendType === provider.type - && fingerprint !== '' - && fingerprint === getProviderFingerprint(pr.defaultBaseUrl) - ); - if (exactPreset) { - return exactPreset; - } - - const host = getProviderHostname(provider.baseUrl); - if (host.endsWith('moonshot.cn')) { - return findPreset('moonshot'); - } - return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl)) - || PROVIDER_PRESETS.find(pr => pr.backendType === provider.type) - || findPreset('custom'); +const matchProviderPreset = (provider: Pick): ProviderPreset => { + const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom'); + return findPreset(presetKey); }; const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [ diff --git a/frontend/src/utils/aiEntryLayout.test.ts b/frontend/src/utils/aiEntryLayout.test.ts new file mode 100644 index 0000000..e44f444 --- /dev/null +++ b/frontend/src/utils/aiEntryLayout.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + SIDEBAR_UTILITY_ITEM_KEYS, + resolveAIEntryPlacement, + resolveAIEdgeHandleAttachment, + resolveAIEdgeHandleDockStyle, + resolveAIEdgeHandleStyle, +} from './aiEntryLayout'; + +describe('ai entry layout', () => { + it('keeps the sidebar utility group free of the AI entry', () => { + expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']); + }); + + it('anchors the AI entry to the content edge', () => { + expect(resolveAIEntryPlacement()).toBe('content-edge'); + }); + + it('attaches the closed handle to the content shell', () => { + expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell'); + }); + + it('attaches the open handle to the panel shell', () => { + expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell'); + }); + + it('keeps the closed handle docked on the content edge', () => { + expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({ + position: 'absolute', + top: 16, + right: 0, + zIndex: 12, + }); + }); + + it('keeps the open handle outside the panel shell to avoid header overlap', () => { + expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({ + position: 'absolute', + top: 16, + right: '100%', + zIndex: 12, + }); + }); + + it('uses the attached active appearance when the AI panel is open', () => { + const style = resolveAIEdgeHandleStyle({ + darkMode: true, + aiPanelVisible: true, + effectiveUiScale: 1, + }); + + expect(style.color).toBe('#ffd666'); + expect(style.background).toBe('rgba(255,214,102,0.12)'); + expect(style.borderRadius).toBe('10px 0 0 10px'); + expect(style.borderRight).toBe('none'); + expect(style.height).toBe(24); + }); + + it('uses the subdued attached appearance when the AI panel is closed', () => { + const style = resolveAIEdgeHandleStyle({ + darkMode: false, + aiPanelVisible: false, + effectiveUiScale: 1, + }); + + expect(style.color).toBe('rgba(22,32,51,0.82)'); + expect(style.background).toBe('rgba(15,23,42,0.04)'); + expect(style.paddingInline).toBe(8); + expect(style.borderRadius).toBe('10px 0 0 10px'); + }); +}); diff --git a/frontend/src/utils/aiEntryLayout.ts b/frontend/src/utils/aiEntryLayout.ts new file mode 100644 index 0000000..a0624fb --- /dev/null +++ b/frontend/src/utils/aiEntryLayout.ts @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react'; + +export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const; + +export type AIEntryPlacement = 'content-edge'; +export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell'; + +export interface ResolveAIEdgeHandleStyleInput { + darkMode: boolean; + aiPanelVisible: boolean; + effectiveUiScale: number; +} + +export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge'; + +export const resolveAIEdgeHandleAttachment = ( + aiPanelVisible: boolean, +): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell'); + +export const resolveAIEdgeHandleDockStyle = ( + attachment: AIEdgeHandleAttachment, +): CSSProperties => ({ + position: 'absolute', + top: 16, + right: attachment === 'panel-shell' ? '100%' : 0, + zIndex: 12, +}); + +export const resolveAIEdgeHandleStyle = ({ + darkMode, + aiPanelVisible, + effectiveUiScale, +}: ResolveAIEdgeHandleStyleInput): CSSProperties => { + const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)'; + const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)'; + const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'; + + return { + height: Math.max(24, Math.round(24 * effectiveUiScale)), + paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)), + borderRadius: '10px 0 0 10px', + border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`, + borderRight: 'none', + background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground, + color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: Math.max(4, Math.round(4 * effectiveUiScale)), + fontSize: Math.max(12, Math.round(12 * effectiveUiScale)), + fontWeight: 600, + lineHeight: 1, + boxShadow: 'none', + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + whiteSpace: 'nowrap', + flexShrink: 0, + }; +}; diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index 98bfc67..c1384e1 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -1,15 +1,37 @@ import { describe, expect, it } from 'vitest'; - +import type { AIProviderType } from '../types'; import { - matchQwenPresetKey, + LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + QWEN_BAILIAN_ANTHROPIC_BASE_URL, QWEN_BAILIAN_MODELS_BASE_URL, QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, QWEN_CODING_PLAN_MODELS, + matchQwenPresetKey, resolvePresetBaseURL, resolvePresetModelSelection, resolvePresetTransport, + resolveProviderPresetKey, } from './aiProviderPresets'; +type PresetMatcher = { + key: string; + backendType: AIProviderType; + defaultBaseUrl: string; + fixedApiFormat?: string; +}; + +const PRESETS: PresetMatcher[] = [ + { key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' }, + { key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL }, + { + key: 'qwen-coding-plan', + backendType: 'custom', + defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + fixedApiFormat: 'claude-cli', + }, + { key: 'custom', backendType: 'custom', defaultBaseUrl: '' }, +]; + describe('ai provider preset helpers', () => { it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => { expect(matchQwenPresetKey({ @@ -18,13 +40,6 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-bailian'); }); - it('maps Coding Plan anthropic URL to the dedicated Coding Plan preset', () => { - expect(matchQwenPresetKey({ - type: 'anthropic', - baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, - })).toBe('qwen-coding-plan'); - }); - it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => { expect(matchQwenPresetKey({ type: 'custom', @@ -33,6 +48,21 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-coding-plan'); }); + it('maps legacy Coding Plan OpenAI config back to the dedicated Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + })).toBe('qwen-coding-plan'); + }); + + it('does not treat a custom OpenAI endpoint as the built-in Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'custom', + apiFormat: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + })).toBeNull(); + }); + it('does not keep a baked-in model list for the Coding Plan preset', () => { expect(QWEN_CODING_PLAN_MODELS).toEqual([ 'qwen3.5-plus', @@ -109,3 +139,47 @@ describe('ai provider preset helpers', () => { }); }); }); + +describe('resolveProviderPresetKey', () => { + it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'openai', + baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('custom'); + }); + + it('仍然能识别当前内置的千问 Coding Plan 预设', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'claude-cli', + baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('qwen-coding-plan'); + }); + + it('仍然能识别当前内置的千问百炼预设', () => { + const key = resolveProviderPresetKey( + { + type: 'anthropic', + apiFormat: undefined, + baseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('qwen-bailian'); + }); +}); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index 7d39a5c..0e96558 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -49,6 +49,13 @@ export interface ResolvePresetTransportResult { apiFormat?: string; } +export interface ProviderPresetMatcher { + key: string; + backendType: AIProviderType; + defaultBaseUrl: string; + fixedApiFormat?: string; +} + export const getProviderHostname = (raw?: string): string => { if (!raw) return ''; try { @@ -71,25 +78,91 @@ export const getProviderFingerprint = (raw?: string): string => { export const matchQwenPresetKey = (provider: Pick): string | null => { const fingerprint = getProviderFingerprint(provider.baseUrl); - const bailianFingerprints = new Set([ - getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL), - getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL), - ]); - if (fingerprint !== '' && bailianFingerprints.has(fingerprint)) { + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL) + && provider.type === 'anthropic' + ) { return 'qwen-bailian'; } - const codingPlanFingerprints = new Set([ - getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL), - getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL), - ]); - if (fingerprint !== '' && codingPlanFingerprints.has(fingerprint)) { + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL) + && provider.type === 'openai' + ) { + return 'qwen-bailian'; + } + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL) + && provider.type === 'custom' + && provider.apiFormat === 'claude-cli' + ) { + return 'qwen-coding-plan'; + } + + if ( + fingerprint !== '' + && fingerprint === getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL) + && provider.type === 'openai' + ) { return 'qwen-coding-plan'; } return null; }; +export const resolveProviderPresetKey = ( + provider: Pick, + presets: ProviderPresetMatcher[], + fallbackKey = 'custom', +): string => { + const qwenPresetKey = matchQwenPresetKey(provider); + if (qwenPresetKey) { + return qwenPresetKey; + } + + const fingerprint = getProviderFingerprint(provider.baseUrl); + const exactPreset = presets.find((preset) => + preset.backendType === provider.type + && fingerprint !== '' + && fingerprint === getProviderFingerprint(preset.defaultBaseUrl) + && (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat), + ); + if (exactPreset) { + return exactPreset.key; + } + + // custom 供应商必须保守处理,避免仅凭 host 错误吞掉用户显式保存的自定义配置。 + if (provider.type === 'custom') { + return fallbackKey; + } + + const host = getProviderHostname(provider.baseUrl); + if (provider.type === 'anthropic' && host.endsWith('moonshot.cn')) { + const moonshotPreset = presets.find((preset) => preset.key === 'moonshot'); + if (moonshotPreset) { + return moonshotPreset.key; + } + } + + const hostPreset = presets.find((preset) => + preset.backendType === provider.type + && host !== '' + && host === getProviderHostname(preset.defaultBaseUrl) + && (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat), + ); + if (hostPreset) { + return hostPreset.key; + } + + const typePreset = presets.find((preset) => preset.backendType === provider.type && !preset.fixedApiFormat); + return typePreset?.key || fallbackKey; +}; + export const resolvePresetModelSelection = ({ presetKey, presetDefaultModel, diff --git a/main.go b/main.go index 8c4d999..d312635 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "embed" aiservice "GoNavi-Wails/internal/ai/service" "GoNavi-Wails/internal/app" @@ -15,9 +14,6 @@ import ( "github.com/wailsapp/wails/v2/pkg/options/windows" ) -//go:embed all:frontend/dist -var assets embed.FS - func main() { // Create an instance of the app structure application := app.NewApp()