Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/__tests__/costTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ const localStorageMock = (() => {
vi.stubGlobal('localStorage', localStorageMock);

describe('calculateCostJpy', () => {
it('gpt-5-nano のコストを正しく計算する', () => {
it('gpt-5.4-nano のコストを正しく計算する', () => {
// input: 1000 tokens × 10/1M = 0.01, output: 500 tokens × 40/1M = 0.02
const cost = calculateCostJpy('gpt-5-nano', 1000, 500);
const cost = calculateCostJpy('gpt-5.4-nano', 1000, 500);
expect(cost).toBeCloseTo(0.03, 4);
});

it('gpt-5-mini のコストを正しく計算する', () => {
it('gpt-5.4-mini のコストを正しく計算する', () => {
// input: 1000 × 50/1M = 0.05, output: 500 × 200/1M = 0.10
const cost = calculateCostJpy('gpt-5-mini', 1000, 500);
const cost = calculateCostJpy('gpt-5.4-mini', 1000, 500);
expect(cost).toBeCloseTo(0.15, 4);
});

Expand All @@ -54,14 +54,14 @@ describe('addUsage / getSessionTotal / getCycleTotal', () => {

it('addUsage でセッション・サイクル累計が増加する', () => {
const before = getSessionTotal();
const cost = addUsage({ modelId: 'gpt-5-nano', promptTokens: 1000, completionTokens: 500 });
const cost = addUsage({ modelId: 'gpt-5.4-nano', promptTokens: 1000, completionTokens: 500 });
expect(cost).toBeGreaterThan(0);
expect(getSessionTotal()).toBeGreaterThan(before);
expect(getCycleTotal()).toBeGreaterThan(0);
});

it('resetCycle でサイクル累計のみリセットされる', () => {
addUsage({ modelId: 'gpt-5-nano', promptTokens: 1000, completionTokens: 500 });
addUsage({ modelId: 'gpt-5.4-nano', promptTokens: 1000, completionTokens: 500 });
const sessionBefore = getSessionTotal();
resetCycle();
expect(getCycleTotal()).toBe(0);
Expand All @@ -83,9 +83,9 @@ describe('checkBudget', () => {
});

it('サイクル累計が ¥5 超で warn を返す', () => {
// サイクル累計を ¥5 超にする: gpt-5-mini × 大量トークン
// サイクル累計を ¥5 超にする: gpt-5.4-mini × 大量トークン
for (let i = 0; i < 50; i++) {
addUsage({ modelId: 'gpt-5-mini', promptTokens: 5000, completionTokens: 2000 });
addUsage({ modelId: 'gpt-5.4-mini', promptTokens: 5000, completionTokens: 2000 });
}
const warning = checkBudget(0.1, true);
expect(warning).not.toBeNull();
Expand Down
40 changes: 20 additions & 20 deletions src/__tests__/modelRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,43 @@ const baseForm: BrainstormForm = {
describe('selectModel', () => {
it('手動選択(auto以外)はそのまま返す', () => {
const input: ModelRouterInput = { taskType: 'generate', depth: 1 };
const result = selectModel('gpt-4.1-mini', input, true);
expect(result.modelId).toBe('gpt-4.1-mini');
const result = selectModel('gpt-5.4-mini', input, true);
expect(result.modelId).toBe('gpt-5.4-mini');
expect(result.reason).toBe('手動選択');
});

it('Free mode + auto → 常に gpt-5-nano', () => {
it('Free mode + auto → 常に gpt-5.4-nano', () => {
const input: ModelRouterInput = { taskType: 'generate', depth: 4, form: baseForm };
const result = selectModel(AUTO_MODEL_ID, input, false);
expect(result.modelId).toBe('gpt-5-nano');
expect(result.modelId).toBe('gpt-5.4-nano');
});

it('Pro + depth 1 + シンプル入力 → gpt-5-nano', () => {
it('Pro + depth 1 + シンプル入力 → gpt-5.4-nano', () => {
const input: ModelRouterInput = { taskType: 'generate', depth: 1, form: baseForm };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-nano');
expect(result.modelId).toBe('gpt-5.4-nano');
});

it('Pro + depth 2 + シンプル入力 → gpt-5-nano', () => {
it('Pro + depth 2 + シンプル入力 → gpt-5.4-nano', () => {
const input: ModelRouterInput = { taskType: 'generate', depth: 2, form: baseForm };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-nano');
expect(result.modelId).toBe('gpt-5.4-nano');
});

it('Pro + depth 3 → gpt-5-mini', () => {
it('Pro + depth 3 → gpt-5.4-mini', () => {
const input: ModelRouterInput = { taskType: 'generate', depth: 3, form: baseForm };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-mini');
expect(result.modelId).toBe('gpt-5.4-mini');
expect(result.reason).toContain('深度3');
});

it('Pro + depth 4 → gpt-5-mini', () => {
it('Pro + depth 4 → gpt-5.4-mini', () => {
const input: ModelRouterInput = { taskType: 'generate', depth: 4, form: baseForm };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-mini');
expect(result.modelId).toBe('gpt-5.4-mini');
});

it('Pro + 課題3件 + 長い目標 → gpt-5-mini', () => {
it('Pro + 課題3件 + 長い目標 → gpt-5.4-mini', () => {
const form: BrainstormForm = {
...baseForm,
teamGoals:
Expand All @@ -68,40 +68,40 @@ describe('selectModel', () => {
};
const input: ModelRouterInput = { taskType: 'generate', depth: 2, form };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-mini');
expect(result.modelId).toBe('gpt-5.4-mini');
expect(result.reason).toContain('課題');
});

it('Pro + 競合データあり → gpt-5-mini', () => {
it('Pro + 競合データあり → gpt-5.4-mini', () => {
const form: BrainstormForm = {
...baseForm,
competitors: [{ name: '競合A', url: 'https://example.com', note: '' }],
};
const input: ModelRouterInput = { taskType: 'generate', depth: 1, form };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-mini');
expect(result.modelId).toBe('gpt-5.4-mini');
expect(result.reason).toContain('競合');
});

it('Pro + KPIデータあり → gpt-5-mini', () => {
it('Pro + KPIデータあり → gpt-5.4-mini', () => {
const form: BrainstormForm = {
...baseForm,
kpis: [{ label: '成約率', value: '15%' }],
};
const input: ModelRouterInput = { taskType: 'generate', depth: 1, form };
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-mini');
expect(result.modelId).toBe('gpt-5.4-mini');
});

it('Pro + トークン2000超 → gpt-5-mini', () => {
it('Pro + トークン2000超 → gpt-5.4-mini', () => {
const longText = 'あ'.repeat(4100); // 4100文字 / 2 = 2050トークン
const input: ModelRouterInput = {
taskType: 'deepDive',
depth: 1,
messages: [{ role: 'user', content: longText }],
};
const result = selectModel(AUTO_MODEL_ID, input, true);
expect(result.modelId).toBe('gpt-5-mini');
expect(result.modelId).toBe('gpt-5.4-mini');
expect(result.reason).toContain('トークン');
});
});
Expand Down
14 changes: 7 additions & 7 deletions src/components/layout/HeaderBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type Story = StoryObj<typeof meta>;
export const FreeMode: Story = {
args: {
proMode: false,
modelLabel: 'Auto',
modelLabel: '5.4 Nano',
connStatus: { status: 'idle', msg: '' },
isDark: false,
lastUsedModel: null,
Expand All @@ -46,22 +46,22 @@ export const FreeMode: Story = {
export const ProMode: Story = {
args: {
proMode: true,
modelLabel: 'Auto',
modelLabel: '5.4 Nano',
connStatus: { status: 'ok', msg: '接続成功' },
isDark: false,
lastUsedModel: 'gpt-5-mini',
lastUsedModel: 'gpt-5.4-mini',
freeRemaining: null,
},
};

/** Auto → 5-nano 表示 */
export const WithAutoResolved: Story = {
/** 5.4 Nano 表示 */
export const WithNanoModel: Story = {
args: {
proMode: true,
modelLabel: 'Auto',
modelLabel: '5.4 Nano',
connStatus: { status: 'ok', msg: '接続成功' },
isDark: true,
lastUsedModel: 'gpt-5-nano',
lastUsedModel: 'gpt-5.4-nano',
freeRemaining: null,
},
};
5 changes: 1 addition & 4 deletions src/components/layout/HeaderBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,9 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({
)}
<div
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border ${T.cardFlat} ${T.t2}`}
title={`使用モデル: ${modelLabel}${lastUsedModel ? `${lastUsedModel}` : ''}`}
title={`使用モデル: ${modelLabel}${lastUsedModel && lastUsedModel !== modelLabel ? `(最終: ${lastUsedModel}` : ''}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The condition lastUsedModel !== modelLabel appears to be comparing a model ID (e.g., 'gpt-5.4-nano') with a user-facing label (e.g., '5.4 Nano'). This comparison will likely not work as intended, as an ID and a label will rarely be equal. To correctly check if the last used model is different from the currently selected one, you should compare their IDs. This might require passing the current modelId to this component and comparing it with lastUsedModel.

>
<span className={T.accentTxt}>◆</span> {modelLabel}
{modelLabel === 'Auto' && lastUsedModel && (
<span className={`${T.t3} text-[10px]`}>→ {lastUsedModel.replace('gpt-', '')}</span>
)}
{!proMode &&
freeRemaining &&
(() => {
Expand Down
5 changes: 2 additions & 3 deletions src/components/modals/HelpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,8 @@ export const HelpModal: React.FC<HelpModalProps> = ({ onClose }) => {
{/* Cost note */}
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-900/15 border border-amber-200 dark:border-amber-700/40">
<p className="text-xs text-amber-700 dark:text-amber-300">
💡 <strong>コストの目安:</strong>{' '}
gpt-4.1-nanoなら1回あたり約$0.001〜0.005(〜0.5〜0.8円)。
gpt-5-nanoでも1回あたり数円程度です。
💡 <strong>コストの目安:</strong> gpt-5.4-nanoなら1回あたり約0.5〜1円程度です。
gpt-5.4-miniでも1回あたり数円程度です。
</p>
</div>

Expand Down
12 changes: 6 additions & 6 deletions src/components/modals/SettingsModal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

/** APIキーなし、Autoモデル、残り回数表示 */
/** APIキーなし、デフォルトモデル、残り回数表示 */
export const FreeMode: Story = {
args: {
modelId: 'auto',
modelId: 'gpt-5.4-nano',
connStatus: { status: 'idle', msg: '' },
apiKey: '',
sessionCost: 0,
Expand All @@ -40,23 +40,23 @@ export const FreeMode: Story = {
/** APIキーあり、コスト表示 */
export const ProMode: Story = {
args: {
modelId: 'auto',
modelId: 'gpt-5.4-nano',
connStatus: { status: 'ok', msg: '接続成功' },
apiKey: 'sk-test-key-1234',
sessionCost: 2.45,
lastUsedModel: 'gpt-5-nano',
lastUsedModel: 'gpt-5.4-nano',
freeRemaining: null,
},
};

/** Free mode 残り少ない */
export const WithRateLimit: Story = {
args: {
modelId: 'auto',
modelId: 'gpt-5.4-nano',
connStatus: { status: 'idle', msg: '' },
apiKey: '',
sessionCost: 0,
lastUsedModel: 'gpt-5-nano',
lastUsedModel: 'gpt-5.4-nano',
freeRemaining: { remaining: 3, limit: 50, resetAt: Date.now() + 3600_000 },
},
};
46 changes: 23 additions & 23 deletions src/components/modals/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from 'lucide-react';
import {
MODELS,
AUTO_MODEL_ID,
DEFAULT_MODEL_ID,
isProMode,
isLocalProvider,
PROVIDER_DEFAULTS,
Expand Down Expand Up @@ -193,12 +193,16 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</button>
</div>
</div>
{isProMode(apiKey) && (
{isProMode(apiKey) ? (
<p className="mt-1 text-xs text-emerald-600 dark:text-emerald-400">
✓ プロモード有効 — フル機能・高深度分析が使えます
</p>
)}
{!isProMode(apiKey) && (
) : apiKey.trim().length > 0 ? (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
⚠ APIキーの形式が正しくありません(sk-...
で始まる20文字以上のキーを入力してください)
</p>
) : (
<p className={`mt-1 text-xs ${T.t3}`}>
入力するとプロモードに切り替わり、全機能が使えます
</p>
Expand Down Expand Up @@ -271,24 +275,21 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
<p className={`text-xs font-medium ${T.t2} mb-1.5`}>モデル</p>
<div className="flex gap-1.5 flex-wrap">
{MODELS.map((m) => {
const isAuto = m.id === AUTO_MODEL_ID;
const isProOnly = m.id !== DEFAULT_MODEL_ID;
const locked = isProOnly && !proMode;
const isSelected = modelId === m.id;
let cls: string;
if (isSelected && isAuto) {
cls =
'bg-emerald-600 dark:bg-emerald-700 border-emerald-500 dark:border-emerald-500 text-white font-medium';
} else if (isSelected) {
cls =
'bg-slate-800 dark:bg-slate-700 border-slate-600 dark:border-slate-500 text-slate-100 font-medium';
} else if (isAuto) {
cls = `${T.btnGhost} border-emerald-200 dark:border-emerald-700/60`;
} else {
cls = `${T.btnGhost} border-slate-200 dark:border-slate-700/60`;
}
const cls = locked
? 'opacity-50 cursor-not-allowed border-slate-200 dark:border-slate-700/60 bg-slate-100 dark:bg-slate-800/40 text-slate-400 dark:text-slate-500'
: isSelected
? 'bg-slate-800 dark:bg-slate-700 border-slate-600 dark:border-slate-500 text-slate-100 font-medium'
: `${T.btnGhost} border-slate-200 dark:border-slate-700/60`;
return (
<button
key={m.id}
disabled={locked}
title={locked ? 'APIキーを入力するとProモードで利用できます' : undefined}
onClick={() => {
if (locked) return;
setModelId(m.id);
setConnStatus({ status: 'idle', msg: '' });
}}
Expand All @@ -298,16 +299,15 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
<span className={isSelected ? 'opacity-70 font-normal' : `${T.t3} font-normal`}>
{m.cost} · {m.t}
</span>
{locked && <span className="ml-1 text-[9px] opacity-60">Pro</span>}
</button>
);
})}
</div>
<p
className={`mt-1.5 text-[10px] ${modelId === AUTO_MODEL_ID ? 'text-emerald-600 dark:text-emerald-400' : T.t3}`}
>
{modelId === AUTO_MODEL_ID
? 'タスクに応じて最適なモデルを自動選択します(おすすめ)'
: '迷ったら「Auto」がおすすめです'}
<p className={`mt-1.5 text-[10px] ${T.t3}`}>
{proMode
? '選択したモデルは自動で保存されます'
: 'APIキーを入力すると上位モデルが選択可能になります'}
</p>
{(sessionCost > 0 || lastUsedModel) && (
<div className={`mt-2 flex items-center gap-3 text-[10px] ${T.t3}`}>
Expand Down
Loading
Loading