diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py
index 4266a2cc..f6078e95 100644
--- a/backend/app/services/agent/llm/ollama_client.py
+++ b/backend/app/services/agent/llm/ollama_client.py
@@ -7,6 +7,7 @@
import httpx
from ....core import settings
+from ..output_schema import to_portable_schema
from .base import LLMClient, LLMError, LLMResult, require_text, wrap_api_error
logger = logging.getLogger(__name__)
@@ -19,8 +20,11 @@ class OllamaClient(LLMClient):
"""ローカル Ollama の /api/chat を呼び出すクライアント。
``format`` に JSON Schema を渡して構造化出力(文法制約)を強制する。
- 構造・許可 field(const)・maxItems は文法レベルで保証されるが、
- maxLength は強制されないため上限超過は呼び出し側で破棄する。
+ ただし llama.cpp の JSON Schema → GBNF 文法変換は ``maxLength`` / ``maxItems``
+ を解釈できず ``failed to parse grammar`` で 400 を返すため、Gemini/OpenAI と同様に
+ ``to_portable_schema`` で数値制約を除去し ``oneOf`` を ``enum`` へ畳んだ移植スキーマを渡す。
+ 許可 field 名(enum)と構造は文法レベルで保証されるが、文字数上限の実強制は
+ 呼び出し側(``chat_service._parse_response`` の破棄ロジック)が担う(二重防衛 / ADR-0013)。
"""
def __init__(self) -> None:
@@ -45,7 +49,8 @@ async def generate(
"model": self._model,
"messages": [{"role": "system", "content": system_prompt}, *messages],
"stream": False,
- "format": output_schema,
+ # maxLength/maxItems を含む生スキーマは llama.cpp の文法変換を壊すため移植スキーマを渡す
+ "format": to_portable_schema(output_schema, drop_additional_properties=False),
# 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する
# (デフォルト 0.8 では小型モデルが架空の資格・技術を捏造しやすい)
"options": {"temperature": 0.2},
diff --git a/backend/app/services/agent/output_schema.py b/backend/app/services/agent/output_schema.py
index 29a413fb..ec66b08f 100644
--- a/backend/app/services/agent/output_schema.py
+++ b/backend/app/services/agent/output_schema.py
@@ -4,9 +4,11 @@
プロンプト(app/prompts/agent_*.md)には機械検証不能な制約(品質基準・
捏造禁止・思考ステップ)のみを書く(ADR-0010「制約の責務分離」)。
-注意: Anthropic の非 strict tool use / Ollama の format(文法制約)は
-maxLength を API 側で強制しない(モデルへの助言扱い)。文字数上限の
-実強制は chat_service._parse_response の破棄ロジックが担う(二重防衛)。
+注意: Anthropic の非 strict tool use は maxLength を API 側で強制しない(モデルへの
+助言扱い)。Ollama(llama.cpp の format → GBNF 文法変換)は maxLength / maxItems を
+解釈できず文法変換自体が失敗するため、to_portable_schema で数値制約を除去してから渡す
+(ADR-0013)。いずれの経路でも文字数上限の実強制は chat_service._parse_response の
+破棄ロジックが担う(二重防衛)。
maxLength は JSON Schema 仕様どおり Unicode 文字数(日本語の len() と一致)。
"""
@@ -84,9 +86,9 @@ def build_tool_definition(input_schema: dict) -> dict:
def to_portable_schema(schema: dict, *, drop_additional_properties: bool = False) -> dict:
"""build_output_schema の出力を、Gemini/OpenAI の構造化出力に通る形へ変換する(ADR-0013)。
- Gemini ``response_schema`` / OpenAI strict ``response_format`` は ``oneOf`` / ``const`` /
- ``maxLength`` / ``maxItems`` といった JSON Schema キーワードを受け付けないか挙動が
- 不安定なため、以下に正規化する:
+ Gemini ``response_schema`` / OpenAI strict ``response_format`` / Ollama ``format``
+ (llama.cpp の GBNF 文法変換)は ``oneOf`` / ``const`` / ``maxLength`` / ``maxItems``
+ といった JSON Schema キーワードを受け付けないか挙動が不安定なため、以下に正規化する:
- operations.items の ``oneOf`` 分岐 → ``field`` を許可値の ``enum`` に畳んだ単一オブジェクト
- ``maxLength`` / ``maxItems`` を除去(上限の実強制は chat_service._parse_response が担う / 二重防衛)
@@ -95,7 +97,8 @@ def to_portable_schema(schema: dict, *, drop_additional_properties: bool = False
- OpenAI strict は ``additionalProperties: false`` が必須 → 残す(drop_additional_properties=False)
- Gemini ``response_schema`` は ``additionalProperties`` 非対応 → 除去する(drop_additional_properties=True)
- Anthropic(tool use)と Ollama(format)は元スキーマをそのまま使うため本関数は通さない。
+ Anthropic(tool use)は oneOf/const/maxLength を解釈できるため元スキーマをそのまま使い、
+ 本関数は通さない。Ollama(format)は数値制約で文法変換が壊れるため本関数を通す。
"""
strip_keys = {"maxLength", "maxItems"}
if drop_additional_properties:
diff --git a/backend/tests/test_llm_clients.py b/backend/tests/test_llm_clients.py
index 934831fc..b486c34d 100644
--- a/backend/tests/test_llm_clients.py
+++ b/backend/tests/test_llm_clients.py
@@ -384,6 +384,52 @@ def test_ollama_client_returns_text_with_zero_usage(monkeypatch) -> None:
assert result.output_tokens == 0
+def test_ollama_client_sends_portable_schema(monkeypatch) -> None:
+ """format には移植スキーマを渡す(llama.cpp の文法変換が maxLength/maxItems/oneOf で
+ 失敗するため)。oneOf は enum へ畳まれ、maxLength / maxItems は除去される。"""
+ import json as _json
+
+ from app.services.agent.llm import ollama_client
+ from app.services.agent.llm.ollama_client import OllamaClient
+
+ captured: dict = {}
+
+ class _CapturingClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args) -> bool:
+ return False
+
+ async def post(self, url, json=None):
+ captured["payload"] = json
+ return _FakeResponse({"message": {"content": '{"message":"ok"}'}})
+
+ monkeypatch.setattr(ollama_client.settings, "get_ollama_base_url", lambda: "http://x")
+ monkeypatch.setattr(ollama_client.settings, "get_ollama_model", lambda: "llama3.2")
+ monkeypatch.setattr(ollama_client.settings, "get_ollama_timeout_seconds", lambda: 1.0)
+ monkeypatch.setattr(
+ ollama_client.httpx, "AsyncClient", lambda **kwargs: _CapturingClient()
+ )
+
+ asyncio.run(
+ OllamaClient().generate(
+ "sys", [{"role": "user", "content": "hi"}],
+ build_output_schema("project"), "ignored"
+ )
+ )
+
+ fmt = captured["payload"]["format"]
+ serialized = _json.dumps(fmt)
+ # 数値制約と oneOf が文法変換を壊すため、いずれも format に残ってはいけない
+ assert "maxLength" not in serialized
+ assert "maxItems" not in serialized
+ assert "oneOf" not in serialized
+ # project の許可 field(description / role)は enum に畳まれている
+ item = fmt["properties"]["operations"]["items"]
+ assert item["properties"]["field"]["enum"] == ["description", "role"]
+
+
def test_ollama_client_http_error_wrapped(monkeypatch) -> None:
"""httpx.HTTPError は LLMError にラップされる。"""
from app.services.agent.llm.ollama_client import OllamaClient
diff --git a/web/src/components/agent/AgentModelBadge.module.css b/web/src/components/agent/AgentModelBadge.module.css
index 8e19d1e0..de2da334 100644
--- a/web/src/components/agent/AgentModelBadge.module.css
+++ b/web/src/components/agent/AgentModelBadge.module.css
@@ -4,16 +4,18 @@
gap: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
- color: var(--text-secondary);
+ /* サイドバー背景はライト/ダーク両テーマで暗色のため、本文用の --text-* ではなく
+ サイドバー専用のテキスト変数を使う(ライトモードで暗背景に暗文字となり読めなくなるのを防ぐ)。 */
+ color: var(--sidebar-text);
}
.label {
- color: var(--text-secondary);
+ color: var(--sidebar-text);
}
.value {
font-weight: 600;
- color: var(--text-primary);
+ color: var(--sidebar-title);
}
.paid {
diff --git a/web/src/components/billing/CreditBalanceBadge.module.css b/web/src/components/billing/CreditBalanceBadge.module.css
index 1abb0ceb..c7d4aee2 100644
--- a/web/src/components/billing/CreditBalanceBadge.module.css
+++ b/web/src/components/billing/CreditBalanceBadge.module.css
@@ -5,7 +5,9 @@
padding: 0.4rem 0.75rem;
margin-bottom: 0.4rem;
font-size: 0.78rem;
- color: var(--text-secondary);
+ /* サイドバー背景は両テーマで暗色のため、本文用の --text-* ではなくサイドバー専用変数を使う
+ (ライトモードで暗背景に暗文字となり読めなくなるのを防ぐ)。 */
+ color: var(--sidebar-text);
border-bottom: 1px solid var(--sidebar-border);
}
@@ -16,18 +18,18 @@
}
.label {
- color: var(--text-secondary);
+ color: var(--sidebar-text);
}
.value {
font-weight: 600;
- color: var(--text-primary);
+ color: var(--sidebar-title);
font-variant-numeric: tabular-nums;
}
.estimate {
font-size: 0.72rem;
- color: var(--text-secondary);
+ color: var(--sidebar-text);
opacity: 0.8;
}
diff --git a/web/src/components/forms/AgentChatWidget.tsx b/web/src/components/forms/AgentChatWidget.tsx
index 2822c906..0a112512 100644
--- a/web/src/components/forms/AgentChatWidget.tsx
+++ b/web/src/components/forms/AgentChatWidget.tsx
@@ -295,7 +295,7 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin }
{entries.length === 0 &&
{AGENT_MESSAGES.EMPTY_STATE}
}
{entries.map((entry, i) => (
{entry.text}
diff --git a/web/src/components/forms/CareerResumeForm.tsx b/web/src/components/forms/CareerResumeForm.tsx
index 3ab1fbc0..bf190c01 100644
--- a/web/src/components/forms/CareerResumeForm.tsx
+++ b/web/src/components/forms/CareerResumeForm.tsx
@@ -1,5 +1,4 @@
-import { CSSProperties, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import type { Dispatch, SetStateAction } from "react";
+import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCareerFormModals } from "../../hooks/career/useCareerFormModals";
@@ -18,10 +17,9 @@ import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview";
import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist";
import { useDocumentForm } from "../../hooks/useDocumentForm";
import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft";
-import { buildCareerPayload, validateCareerForm } from "../../payloadBuilders";
-import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders";
+import { buildCareerPayload } from "../../payloadBuilders";
import { buildCareerChanges } from "../../utils/careerDiff";
-import type { CareerTextFieldKey } from "../../formTypes";
+import { useCareerFormValidationFocus } from "../../hooks/career/useCareerFormValidationFocus";
import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData";
import { useCareerExportActions } from "../../hooks/career/useCareerExportActions";
import { useMessageToast } from "../ui/toast";
@@ -126,13 +124,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
}
}, [isAuthenticated, form]);
- /**
- * 保存前バリデーション(項目バリデーション)のメッセージ。
- * 保存/削除/PDF などの非同期処理の成否はトーストで通知するが、
- * 入力エラーは該当フィールドのフォーカス・赤枠とセットでフォーム内にインライン表示する。
- */
- const [validationError, setValidationError] = useState
(null);
-
const { items: techStackOptions, loading: techLoading } = useTechnologyStacks();
const { items: qualificationOptions, loading: qualLoading } = useQualifications();
const qualificationNames = qualificationOptions.map((item) => item.name);
@@ -186,85 +177,26 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
/** フォームデータ・技術スタック・資格の3つが揃った時に送信可能 */
const canSubmit = !loading && !techLoading && !qualLoading;
- /**
- * バリデーション失敗フィールドの位置と nonce。保存時にセットし、
- * 該当入力へのフォーカス・赤枠表示・折りたたみ自動展開に使う。
- * nonce は「同じフィールドで再度保存した時」も折りたたみ展開 effect を再発火させるための鍵。
- */
- const [focusTarget, setFocusTarget] = useState<{
- locator: CareerFieldLocator;
- nonce: number;
- } | null>(null);
- const focusNonceRef = useRef(0);
-
- /** 編集が入ったらフォーカス強調を解除する(赤枠を消す)setForm ラッパー。 */
- const setFormAndClearFocus = useCallback>>(
- (action) => {
- setFocusTarget(null);
- setValidationError(null);
- setForm(action);
- },
- [setForm],
- );
-
- const onChangeField = (key: CareerTextFieldKey, value: string) => {
- setFocusTarget(null);
- setValidationError(null);
- setForm((prev) => ({ ...prev, [key]: value }));
- };
-
- /** バリデーション失敗を画面へ反映する(メッセージ・フォーカス・モーダル自動展開)。 */
- const applyValidationError = (validation: NonNullable>) => {
- setValidationError(validation.message);
- focusNonceRef.current += 1;
- setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current });
- // 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く
- // (隠れた textarea には直接フォーカスできないため)。
- if (
- validation.locator.kind === "career_summary" ||
- validation.locator.kind === "self_pr"
- ) {
- setEditingField(validation.locator.kind);
- }
- };
-
- const onSubmit = (event: FormEvent) => {
- event.preventDefault();
-
- // 未ログインのお試し入力: 全項目の入力完了は求めず(カジュアルな体験を優先)、
- // 氏名だけ確認して(空の経歴書でログインさせない)ドラフトを退避し、ログインを促す。
- // 残りの項目検証はログイン後の実保存時にサーバ側で行う。
- if (!isAuthenticated) {
- if (!form.full_name.trim()) {
- const validation = validateCareerForm(form);
- if (validation) applyValidationError(validation);
- return;
- }
- setValidationError(null);
- setFocusTarget(null);
- // 入力内容は effect で sessionStorage に退避済み。ログインを促す。
- requestLogin();
- return;
- }
-
- // 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。
- const validation = validateCareerForm(form);
- if (validation) {
- applyValidationError(validation);
- return;
- }
- setValidationError(null);
- setFocusTarget(null);
- // 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。
- if (changes.length === 0) {
- void save();
- return;
- }
- setShowSaveConfirm(true);
- };
-
- const focusLocator = focusTarget?.locator ?? null;
- const focusNonce = focusTarget?.nonce ?? 0;
+ // バリデーション結果の画面反映(メッセージ・フォーカス強調・モーダル自動展開・送信分岐)。
+ const {
+ validationError,
+ focusLocator,
+ focusNonce,
+ setFormAndClearFocus,
+ onChangeField,
+ onSubmit,
+ } = useCareerFormValidationFocus({
+ form,
+ setForm,
+ isAuthenticated,
+ changeCount: changes.length,
+ save,
+ openSaveConfirm: () => setShowSaveConfirm(true),
+ requestLogin,
+ // ゲスト入力はログイン遷移の直前に同期退避する(effect の未反映で最後の入力を失わないため)。
+ persistDraft: saveCareerDraft,
+ openMarkdownField: setEditingField,
+ });
return (
<>
diff --git a/web/src/hooks/career/useAgentChat.test.ts b/web/src/hooks/career/useAgentChat.test.ts
index cef8af8d..27fe3faa 100644
--- a/web/src/hooks/career/useAgentChat.test.ts
+++ b/web/src/hooks/career/useAgentChat.test.ts
@@ -1,7 +1,9 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AgentModelAlias, ExperienceTarget, ProjectTarget } from "../../api/types";
import type { CareerFormState } from "../../payloadBuilders";
+import type { AgentScope } from "../../utils/agentOperations";
import { useAgentChat } from "./useAgentChat";
const postAgentChatMock = vi.fn();
@@ -21,6 +23,24 @@ const form = {
qualifications: [],
} as unknown as CareerFormState;
+type ChatHook = { current: ReturnType };
+
+/**
+ * 送信の共通 arrange(act + send のラップ)。各テストでスコープ・対象・プロンプトだけ差し替える。
+ * model 省略時はフックのデフォルト(haiku)に委ねる。
+ */
+async function sendChat(
+ result: ChatHook,
+ scope: AgentScope,
+ target: ProjectTarget | ExperienceTarget | null,
+ prompt: string,
+ model?: AgentModelAlias,
+) {
+ await act(async () => {
+ await result.current.send(form, scope, target, prompt, model);
+ });
+}
+
beforeEach(() => {
postAgentChatMock.mockReset();
});
@@ -33,9 +53,7 @@ describe("useAgentChat", () => {
});
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "career_summary", null, "改善して");
- });
+ await sendChat(result, "career_summary", null, "改善して");
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries[0]).toMatchObject({ role: "user", text: "改善して" });
@@ -50,6 +68,16 @@ describe("useAgentChat", () => {
);
});
+ it("user / assistant エントリは描画キー用の一意な id を持つ", async () => {
+ postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
+ const { result } = renderHook(() => useAgentChat());
+
+ await sendChat(result, "self_pr", null, "改善して");
+
+ const ids = result.current.entries.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
it("曖昧入力応答の suggestions(依頼文候補)をエントリに保持する", async () => {
postAgentChatMock.mockResolvedValue({
message: "どの方向で改善しますか?",
@@ -58,9 +86,7 @@ describe("useAgentChat", () => {
});
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "いい感じにして");
- });
+ await sendChat(result, "self_pr", null, "いい感じにして");
expect(result.current.entries[1].suggestions).toEqual([
"300字に要約して",
@@ -72,9 +98,7 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
expect(result.current.entries[1].suggestions).toBeNull();
});
@@ -83,9 +107,7 @@ describe("useAgentChat", () => {
postAgentChatMock.mockRejectedValue(new Error("AI の応答取得に失敗しました。"));
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
await waitFor(() => {
expect(result.current.error).toBe("AI の応答取得に失敗しました。");
@@ -102,9 +124,7 @@ describe("useAgentChat", () => {
});
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
act(() => {
result.current.markApplied(1);
});
@@ -116,9 +136,7 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案なし", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
expect(result.current.entries[1].operations).toBeNull();
});
@@ -127,9 +145,7 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
});
@@ -141,12 +157,8 @@ describe("useAgentChat", () => {
});
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
- await act(async () => {
- await result.current.send(form, "self_pr", null, "もっと短くして");
- });
+ await sendChat(result, "self_pr", null, "改善して");
+ await sendChat(result, "self_pr", null, "もっと短くして");
expect(postAgentChatMock).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -177,12 +189,8 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
- await act(async () => {
- await result.current.send(form, "self_pr", null, "300字に要約して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
+ await sendChat(result, "self_pr", null, "300字に要約して");
// 選択肢を選んだ次の送信時、history の assistant エントリに suggestions が含まれ、
// LLM が「前ターンで選択肢を提示した」文脈を受け取れる(選択肢ループ回帰防止)
@@ -209,12 +217,8 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "失敗する依頼");
- });
- await act(async () => {
- await result.current.send(form, "self_pr", null, "再送する依頼");
- });
+ await sendChat(result, "self_pr", null, "失敗する依頼");
+ await sendChat(result, "self_pr", null, "再送する依頼");
expect(postAgentChatMock).toHaveBeenLastCalledWith(
expect.objectContaining({ history: [] }),
@@ -227,9 +231,7 @@ describe("useAgentChat", () => {
// 5 回目の送信時点で過去 4 往復(8 エントリ)が 6 件に切り詰められる
for (let i = 1; i <= 5; i++) {
- await act(async () => {
- await result.current.send(form, "self_pr", null, `依頼${i}`);
- });
+ await sendChat(result, "self_pr", null, `依頼${i}`);
}
const calls = postAgentChatMock.mock.calls;
@@ -245,9 +247,7 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して");
- });
+ await sendChat(result, "self_pr", null, "改善して");
expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ model: "haiku" }));
});
@@ -256,9 +256,7 @@ describe("useAgentChat", () => {
postAgentChatMock.mockResolvedValue({ message: "提案です", operations: [] });
const { result } = renderHook(() => useAgentChat());
- await act(async () => {
- await result.current.send(form, "self_pr", null, "改善して", "sonnet");
- });
+ await sendChat(result, "self_pr", null, "改善して", "sonnet");
expect(postAgentChatMock).toHaveBeenCalledWith(expect.objectContaining({ model: "sonnet" }));
});
@@ -268,9 +266,7 @@ describe("useAgentChat", () => {
const { result } = renderHook(() => useAgentChat());
const expTarget = { experience_index: 0 };
- await act(async () => {
- await result.current.send(form, "experience", expTarget, "事業内容を改善して");
- });
+ await sendChat(result, "experience", expTarget, "事業内容を改善して");
expect(postAgentChatMock).toHaveBeenCalledWith(
expect.objectContaining({ scope: "experience", target: expTarget }),
diff --git a/web/src/hooks/career/useAgentChat.ts b/web/src/hooks/career/useAgentChat.ts
index c3e44a2a..07bcb269 100644
--- a/web/src/hooks/career/useAgentChat.ts
+++ b/web/src/hooks/career/useAgentChat.ts
@@ -6,7 +6,7 @@
* operations の適用は呼び出し側(ウィジェット → CareerResumeForm の setForm)が行う。
*/
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { postAgentChat } from "../../api/agent";
import type {
@@ -25,6 +25,8 @@ import {
/** チャット 1 件分(ユーザー発話 or AI 応答)。 */
export type AgentChatEntry = {
+ /** 描画時の安定キー。マウント内で単調増加し、末尾追加でも再利用されない。 */
+ id: number;
role: "user" | "assistant";
text: string;
/** AI 応答のみ。フォームへ反映できる差分(適用済みなら null にする) */
@@ -63,6 +65,8 @@ export function useAgentChat() {
const [entries, setEntries] = useState([]);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
+ // 描画キー用の単調増加カウンタ(マウント内で一意。state 更新の副作用にしないよう外で採番)。
+ const nextIdRef = useRef(0);
const send = useCallback(
async (
@@ -77,6 +81,7 @@ export function useAgentChat() {
setEntries((prev) => [
...prev,
{
+ id: nextIdRef.current++,
role: "user",
text: prompt,
operations: null,
@@ -99,6 +104,7 @@ export function useAgentChat() {
setEntries((prev) => [
...prev,
{
+ id: nextIdRef.current++,
role: "assistant",
text: response.message,
operations: response.operations?.length ? response.operations : null,
diff --git a/web/src/hooks/career/useCareerDirty.test.ts b/web/src/hooks/career/useCareerDirty.test.ts
index bfe40428..8baf2ec6 100644
--- a/web/src/hooks/career/useCareerDirty.test.ts
+++ b/web/src/hooks/career/useCareerDirty.test.ts
@@ -1,50 +1,12 @@
import { renderHook } from "@testing-library/react";
import { describe, it, expect } from "vitest";
-import {
- blankCareerClient,
- blankCareerExperience,
- blankCareerProject,
- blankCareerTechnologyStack,
- blankResumeQualification,
-} from "../../constants";
-import type { CareerFormState } from "../../payloadBuilders";
+import { blankCareerExperience } from "../../constants";
+import { buildSampleCareerForm } from "../../test/factories/careerForm";
import { useCareerDirty } from "./useCareerDirty";
/** 標準的なフォーム初期状態を作るヘルパ */
-function buildForm(overrides: Partial = {}): CareerFormState {
- return {
- full_name: "山田 太郎",
- email: "yamada@example.com",
- github_url: "",
- career_summary: "サマリー",
- self_pr: "自己PR",
- experiences: [
- {
- ...blankCareerExperience,
- company: "株式会社A",
- business_description: "受託開発",
- start_date: "2020-04",
- clients: [
- {
- ...blankCareerClient,
- projects: [
- {
- ...blankCareerProject,
- name: "プロジェクトX",
- technology_stacks: [{ ...blankCareerTechnologyStack }],
- },
- ],
- },
- ],
- },
- ],
- qualifications: [
- { ...blankResumeQualification, name: "基本情報", acquired_date: "2021-04-01" },
- ],
- ...overrides,
- };
-}
+const buildForm = buildSampleCareerForm;
describe("useCareerDirty", () => {
it("baseline が null の場合はすべて false を返す(未ロード)", () => {
diff --git a/web/src/hooks/career/useCareerFormValidationFocus.test.ts b/web/src/hooks/career/useCareerFormValidationFocus.test.ts
new file mode 100644
index 00000000..463d9091
--- /dev/null
+++ b/web/src/hooks/career/useCareerFormValidationFocus.test.ts
@@ -0,0 +1,136 @@
+import { act, renderHook } from "@testing-library/react";
+import type { FormEvent } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { buildSampleCareerForm } from "../../test/factories/careerForm";
+import { useCareerFormValidationFocus } from "./useCareerFormValidationFocus";
+
+const validateCareerFormMock = vi.fn();
+
+vi.mock("../../payloadBuilders", () => ({
+ validateCareerForm: (...args: unknown[]) => validateCareerFormMock(...args),
+}));
+
+/** preventDefault だけ持つ最小の submit イベント。 */
+const submitEvent = () => ({ preventDefault: vi.fn() }) as unknown as FormEvent;
+
+type Overrides = Partial[0]>;
+
+function setup(overrides: Overrides = {}) {
+ const params = {
+ form: buildSampleCareerForm(),
+ setForm: vi.fn(),
+ isAuthenticated: true,
+ changeCount: 0,
+ save: vi.fn(),
+ openSaveConfirm: vi.fn(),
+ requestLogin: vi.fn(),
+ persistDraft: vi.fn(),
+ openMarkdownField: vi.fn(),
+ ...overrides,
+ };
+ const view = renderHook((p: typeof params) => useCareerFormValidationFocus(p), {
+ initialProps: params,
+ });
+ return { view, params };
+}
+
+beforeEach(() => {
+ validateCareerFormMock.mockReset();
+});
+
+describe("useCareerFormValidationFocus", () => {
+ it("未ログインで氏名が空なら、ログイン導線へ流さずバリデーションを表示する", () => {
+ validateCareerFormMock.mockReturnValue({
+ message: "氏名を入力してください",
+ locator: { kind: "full_name" },
+ });
+ const { view, params } = setup({
+ isAuthenticated: false,
+ form: buildSampleCareerForm({ full_name: " " }),
+ });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+
+ expect(params.requestLogin).not.toHaveBeenCalled();
+ expect(view.result.current.validationError).toBe("氏名を入力してください");
+ });
+
+ it("未ログインで氏名があれば、保存せずログイン導線へ流す", () => {
+ const { view, params } = setup({
+ isAuthenticated: false,
+ form: buildSampleCareerForm({ full_name: "山田 太郎" }),
+ });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+
+ expect(params.requestLogin).toHaveBeenCalledTimes(1);
+ expect(params.save).not.toHaveBeenCalled();
+ expect(view.result.current.validationError).toBeNull();
+ });
+
+ it("未ログインのログイン遷移前に、現在のフォームを同期退避してから requestLogin する", () => {
+ const form = buildSampleCareerForm({ full_name: "山田 太郎" });
+ const { view, params } = setup({ isAuthenticated: false, form });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+
+ // 最新フォームを退避してからログイン導線へ(取りこぼし防止)
+ expect(params.persistDraft).toHaveBeenCalledWith(form);
+ const persistOrder = (params.persistDraft as ReturnType).mock
+ .invocationCallOrder[0];
+ const loginOrder = (params.requestLogin as ReturnType).mock
+ .invocationCallOrder[0];
+ expect(persistOrder).toBeLessThan(loginOrder);
+ });
+
+ it("自己PR / 職務要約の失敗時は該当 Markdown モーダルを自動で開く", () => {
+ validateCareerFormMock.mockReturnValue({
+ message: "職務要約を入力してください",
+ locator: { kind: "career_summary" },
+ });
+ const { view, params } = setup({ isAuthenticated: true });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+
+ expect(params.openMarkdownField).toHaveBeenCalledWith("career_summary");
+ expect(view.result.current.focusLocator).toEqual({ kind: "career_summary" });
+ expect(view.result.current.validationError).toBe("職務要約を入力してください");
+ });
+
+ it("検証 OK かつ変更なしなら確認を挟まず直接保存する", () => {
+ validateCareerFormMock.mockReturnValue(null);
+ const { view, params } = setup({ isAuthenticated: true, changeCount: 0 });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+
+ expect(params.save).toHaveBeenCalledTimes(1);
+ expect(params.openSaveConfirm).not.toHaveBeenCalled();
+ });
+
+ it("検証 OK かつ変更ありなら保存確認モーダルを開く", () => {
+ validateCareerFormMock.mockReturnValue(null);
+ const { view, params } = setup({ isAuthenticated: true, changeCount: 2 });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+
+ expect(params.openSaveConfirm).toHaveBeenCalledTimes(1);
+ expect(params.save).not.toHaveBeenCalled();
+ });
+
+ it("フィールド編集でフォーカス強調(赤枠)とエラー表示が解除される", () => {
+ validateCareerFormMock.mockReturnValue({
+ message: "職務要約を入力してください",
+ locator: { kind: "career_summary" },
+ });
+ const { view } = setup({ isAuthenticated: true });
+
+ act(() => view.result.current.onSubmit(submitEvent()));
+ expect(view.result.current.focusLocator).not.toBeNull();
+
+ act(() => view.result.current.onChangeField("career_summary", "改善後の要約"));
+
+ expect(view.result.current.focusLocator).toBeNull();
+ expect(view.result.current.validationError).toBeNull();
+ });
+});
diff --git a/web/src/hooks/career/useCareerFormValidationFocus.ts b/web/src/hooks/career/useCareerFormValidationFocus.ts
new file mode 100644
index 00000000..922ea677
--- /dev/null
+++ b/web/src/hooks/career/useCareerFormValidationFocus.ts
@@ -0,0 +1,161 @@
+/**
+ * 職務経歴書フォームの「保存前バリデーション結果を画面へ反映する」責務をまとめたフック。
+ *
+ * CareerResumeForm の本体には UI 構成だけを残し、バリデーション失敗時の
+ * インラインメッセージ・該当フィールドのフォーカス強調(赤枠)・隠れフィールド
+ * (自己PR / 職務要約)のモーダル自動展開・送信フロー分岐をここへ集約する。
+ */
+
+import { useCallback, useRef, useState } from "react";
+import type { Dispatch, FormEvent, SetStateAction } from "react";
+
+import type { CareerTextFieldKey } from "../../formTypes";
+import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders";
+import { validateCareerForm } from "../../payloadBuilders";
+
+type UseCareerFormValidationFocusParams = {
+ form: CareerFormState;
+ setForm: Dispatch>;
+ isAuthenticated: boolean;
+ /** 編集中フォームと保存済みの変更点件数。0 件なら確認を挟まず保存する。 */
+ changeCount: number;
+ /** 変更が無いときの直接保存(戻り値は使わず void で発火する)。 */
+ save: () => unknown;
+ /** 変更があるときに開く保存確認モーダル。 */
+ openSaveConfirm: () => void;
+ /** 未ログインで保存を試みたときのログイン導線。 */
+ requestLogin: () => void;
+ /**
+ * ログイン往復の直前に現在のフォームを同期的に退避する。
+ * 通常は CareerResumeForm の effect が入力のたびに退避するが、最後の入力直後に
+ * 送信されると effect が未反映のままログイン遷移して入力を失う恐れがあるため、
+ * ここで同期保存して取りこぼしを防ぐ。
+ */
+ persistDraft?: (form: CareerFormState) => void;
+ /** career_summary / self_pr の失敗時に該当 Markdown モーダルを自動で開く。 */
+ openMarkdownField: (field: "career_summary" | "self_pr") => void;
+};
+
+export function useCareerFormValidationFocus({
+ form,
+ setForm,
+ isAuthenticated,
+ changeCount,
+ save,
+ openSaveConfirm,
+ requestLogin,
+ persistDraft,
+ openMarkdownField,
+}: UseCareerFormValidationFocusParams) {
+ /**
+ * 保存前バリデーション(項目バリデーション)のメッセージ。
+ * 保存/削除/PDF などの非同期処理の成否はトーストで通知するが、
+ * 入力エラーは該当フィールドのフォーカス・赤枠とセットでフォーム内にインライン表示する。
+ */
+ const [validationError, setValidationError] = useState(null);
+
+ /**
+ * バリデーション失敗フィールドの位置と nonce。保存時にセットし、
+ * 該当入力へのフォーカス・赤枠表示・折りたたみ自動展開に使う。
+ * nonce は「同じフィールドで再度保存した時」も折りたたみ展開 effect を再発火させるための鍵。
+ */
+ const [focusTarget, setFocusTarget] = useState<{
+ locator: CareerFieldLocator;
+ nonce: number;
+ } | null>(null);
+ const focusNonceRef = useRef(0);
+
+ /** 編集が入ったらフォーカス強調を解除する(赤枠を消す)setForm ラッパー。 */
+ const setFormAndClearFocus = useCallback>>(
+ (action) => {
+ setFocusTarget(null);
+ setValidationError(null);
+ setForm(action);
+ },
+ [setForm],
+ );
+
+ const onChangeField = useCallback(
+ (key: CareerTextFieldKey, value: string) => {
+ setFocusTarget(null);
+ setValidationError(null);
+ setForm((prev) => ({ ...prev, [key]: value }));
+ },
+ [setForm],
+ );
+
+ /** バリデーション失敗を画面へ反映する(メッセージ・フォーカス・モーダル自動展開)。 */
+ const applyValidationError = useCallback(
+ (validation: NonNullable>) => {
+ setValidationError(validation.message);
+ focusNonceRef.current += 1;
+ setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current });
+ // 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く
+ // (隠れた textarea には直接フォーカスできないため)。
+ if (
+ validation.locator.kind === "career_summary" ||
+ validation.locator.kind === "self_pr"
+ ) {
+ openMarkdownField(validation.locator.kind);
+ }
+ },
+ [openMarkdownField],
+ );
+
+ const onSubmit = useCallback(
+ (event: FormEvent) => {
+ event.preventDefault();
+
+ // 未ログインのお試し入力: 全項目の入力完了は求めず(カジュアルな体験を優先)、
+ // 氏名だけ確認して(空の経歴書でログインさせない)ドラフトを退避し、ログインを促す。
+ // 残りの項目検証はログイン後の実保存時にサーバ側で行う。
+ if (!isAuthenticated) {
+ if (!form.full_name.trim()) {
+ const validation = validateCareerForm(form);
+ if (validation) applyValidationError(validation);
+ return;
+ }
+ setValidationError(null);
+ setFocusTarget(null);
+ // ログイン遷移の直前に最新フォームを同期退避してから促す(effect 任せにせず取りこぼし防止)。
+ persistDraft?.(form);
+ requestLogin();
+ return;
+ }
+
+ // 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。
+ const validation = validateCareerForm(form);
+ if (validation) {
+ applyValidationError(validation);
+ return;
+ }
+ setValidationError(null);
+ setFocusTarget(null);
+ // 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。
+ if (changeCount === 0) {
+ void save();
+ return;
+ }
+ openSaveConfirm();
+ },
+ [
+ isAuthenticated,
+ form,
+ applyValidationError,
+ requestLogin,
+ persistDraft,
+ changeCount,
+ save,
+ openSaveConfirm,
+ ],
+ );
+
+ return {
+ validationError,
+ focusLocator: focusTarget?.locator ?? null,
+ focusNonce: focusTarget?.nonce ?? 0,
+ setFormAndClearFocus,
+ onChangeField,
+ onSubmit,
+ };
+}
diff --git a/web/src/hooks/useAgentUsageSummary.ts b/web/src/hooks/useAgentUsageSummary.ts
index ff735b60..2c5afa00 100644
--- a/web/src/hooks/useAgentUsageSummary.ts
+++ b/web/src/hooks/useAgentUsageSummary.ts
@@ -3,44 +3,32 @@
*
* モデル選択モーダルが開いたときに取得し、各モデルカードへ
* 「これまでの利用回数・消費クレジット」と残回数目安を表示する。
+ * 取得ライフサイクルは useAsyncResource に委譲し、ここではエイリアス → サマリの
+ * 引きやすい Map への変換とアクセサだけを担う。
*/
-import { useCallback, useEffect, useState } from "react";
+import { useCallback } from "react";
import { getAgentUsageSummary } from "../api/billing";
import type { AgentModelAlias, AgentUsageSummaryEntry } from "../api/types";
import { FALLBACK_MESSAGES } from "../constants/messages";
+import { useAsyncResource } from "./useAsyncResource";
/** エイリアス → サマリの引きやすい Map に変換した状態。 */
export type UsageByModel = Record;
export function useAgentUsageSummary(enabled: boolean) {
- const [usageByModel, setUsageByModel] = useState({});
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const refresh = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
+ const { data: usageByModel, loading, error, refresh } = useAsyncResource(
+ async () => {
const entries = await getAgentUsageSummary();
const map: UsageByModel = {};
for (const entry of entries) {
map[entry.model] = entry;
}
- setUsageByModel(map);
- } catch (e) {
- setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.USAGE_SUMMARY);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- if (enabled) {
- void refresh();
- }
- }, [enabled, refresh]);
+ return map;
+ },
+ { enabled, initialData: {}, fallbackMessage: FALLBACK_MESSAGES.USAGE_SUMMARY },
+ );
/** 指定モデルのサマリを返す(未利用なら undefined)。 */
const getUsage = useCallback(
diff --git a/web/src/hooks/useAsyncResource.test.ts b/web/src/hooks/useAsyncResource.test.ts
new file mode 100644
index 00000000..ba030280
--- /dev/null
+++ b/web/src/hooks/useAsyncResource.test.ts
@@ -0,0 +1,150 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { useAsyncResource } from "./useAsyncResource";
+
+const FALLBACK = "取得に失敗しました";
+
+describe("useAsyncResource", () => {
+ it("enabled=true で取得し data に反映する", async () => {
+ const fetcher = vi.fn().mockResolvedValue(42);
+
+ const { result } = renderHook(() =>
+ useAsyncResource(fetcher, { enabled: true, initialData: 0, fallbackMessage: FALLBACK }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.data).toBe(42);
+ });
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("enabled=false の間は取得せず initialData のまま", () => {
+ const fetcher = vi.fn().mockResolvedValue(42);
+
+ const { result } = renderHook(() =>
+ useAsyncResource(fetcher, { enabled: false, initialData: 0, fallbackMessage: FALLBACK }),
+ );
+
+ expect(fetcher).not.toHaveBeenCalled();
+ expect(result.current.data).toBe(0);
+ // 取得が走らないので最初から loading=false
+ expect(result.current.loading).toBe(false);
+ });
+
+ it("Error の reject では message を、それ以外では fallback を error に入れる", async () => {
+ const fetcher = vi.fn().mockRejectedValue(new Error("固有エラー"));
+ const { result, rerender } = renderHook(
+ (props: { f: () => Promise }) =>
+ useAsyncResource(props.f, { enabled: true, initialData: 0, fallbackMessage: FALLBACK }),
+ { initialProps: { f: fetcher } },
+ );
+
+ await waitFor(() => {
+ expect(result.current.error).toBe("固有エラー");
+ });
+ expect(result.current.loading).toBe(false);
+
+ const stringRejector = vi.fn().mockRejectedValue("文字列例外");
+ rerender({ f: stringRejector });
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.error).toBe(FALLBACK);
+ });
+
+ it("古い refresh 応答は新しい応答を上書きしない(seq ガード)", async () => {
+ let resolveStale: (v: number) => void = () => {};
+ const stalePending = new Promise((resolve) => {
+ resolveStale = resolve;
+ });
+ // enabled=false で自動取得を抑止し、手動 refresh の順序だけを検証する
+ const fetcher = vi
+ .fn<() => Promise>()
+ .mockReturnValueOnce(stalePending) // seq=1(保留)
+ .mockResolvedValueOnce(730); // seq=2(先に解決)
+
+ const { result } = renderHook(() =>
+ useAsyncResource(fetcher, { enabled: false, initialData: 0, fallbackMessage: FALLBACK }),
+ );
+
+ await act(async () => {
+ void result.current.refresh(); // seq=1
+ await result.current.refresh(); // seq=2 → 730
+ });
+ expect(result.current.data).toBe(730);
+
+ // 後から古い応答が解決しても最新(730)を保つ
+ await act(async () => {
+ resolveStale(9_999);
+ await stalePending;
+ });
+ expect(result.current.data).toBe(730);
+ });
+
+ it("Error でも message が空文字なら fallback を使う", async () => {
+ const fetcher = vi.fn().mockRejectedValue(new Error(""));
+ const { result } = renderHook(() =>
+ useAsyncResource(fetcher, { enabled: true, initialData: 0, fallbackMessage: FALLBACK }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.error).toBe(FALLBACK);
+ });
+ });
+
+ it("enabled が true→false に変わると進行中の応答は反映されない", async () => {
+ let resolvePending: (v: number) => void = () => {};
+ const pending = new Promise((resolve) => {
+ resolvePending = resolve;
+ });
+ const fetcher = vi.fn().mockReturnValue(pending);
+ const { result, rerender } = renderHook(
+ (props: { enabled: boolean }) =>
+ useAsyncResource(fetcher, {
+ enabled: props.enabled,
+ initialData: 0,
+ fallbackMessage: FALLBACK,
+ }),
+ { initialProps: { enabled: true } },
+ );
+ // enabled=true で実際に取得が走ったことを確認(loading 初期値だけでは自動 fetch の退行を拾えない)
+ await waitFor(() => {
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+ // pending 中なので loading=true
+ expect(result.current.loading).toBe(true);
+
+ // 無効化で進行中リクエストを stale 化し loading も解除する
+ rerender({ enabled: false });
+ expect(result.current.loading).toBe(false);
+
+ // 後から解決しても data は initialData のまま(契約: 無効中は state を更新しない)
+ await act(async () => {
+ resolvePending(999);
+ await pending;
+ });
+ expect(result.current.data).toBe(0);
+ });
+
+ it("enabled が false→true に変わると取得が走る", async () => {
+ const fetcher = vi.fn().mockResolvedValue(7);
+ const { result, rerender } = renderHook(
+ (props: { enabled: boolean }) =>
+ useAsyncResource(fetcher, {
+ enabled: props.enabled,
+ initialData: 0,
+ fallbackMessage: FALLBACK,
+ }),
+ { initialProps: { enabled: false } },
+ );
+
+ expect(fetcher).not.toHaveBeenCalled();
+
+ rerender({ enabled: true });
+ await waitFor(() => {
+ expect(result.current.data).toBe(7);
+ });
+ });
+});
diff --git a/web/src/hooks/useAsyncResource.ts b/web/src/hooks/useAsyncResource.ts
new file mode 100644
index 00000000..6e859c55
--- /dev/null
+++ b/web/src/hooks/useAsyncResource.ts
@@ -0,0 +1,75 @@
+/**
+ * enabled で取得可否を制御する単発フェッチの共通フック。
+ *
+ * 「enabled が true の間だけ取得 → loading / error / data を管理 → refresh で再取得」
+ * という同型のロジックを各データ取得フック(残高・使用量・レート等)が個別に持っていたため、
+ * ここへ集約する。`refresh` が重なったときに古い応答が新しい状態を上書きしないよう
+ * リクエスト順序(seq)ガードを内蔵する。
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type UseAsyncResourceOptions = {
+ /** false の間は自動取得しない(無料モデル選択中に残高 API を叩かない等)。省略時は常に取得。 */
+ enabled?: boolean;
+ /** data の初期値(未取得状態を表す値)。 */
+ initialData: T;
+ /** 取得失敗時、Error でない / message が無い場合に使う日本語フォールバック。 */
+ fallbackMessage: string;
+};
+
+export type UseAsyncResourceReturn = {
+ data: T;
+ loading: boolean;
+ error: string | null;
+ /** 手動再取得。呼び出し側が購入・送信後などに最新化するのに使う。 */
+ refresh: () => Promise;
+};
+
+export function useAsyncResource(
+ fetcher: () => Promise,
+ { enabled = true, initialData, fallbackMessage }: UseAsyncResourceOptions,
+): UseAsyncResourceReturn {
+ const [data, setData] = useState(initialData);
+ // enabled なら初回 effect で即取得が走るため、最初から loading 表示にしてちらつきを防ぐ。
+ const [loading, setLoading] = useState(enabled);
+ const [error, setError] = useState(null);
+ // refresh が重なったとき、古い応答が新しい状態を上書きしないよう最新の seq だけ反映する。
+ const requestSeqRef = useRef(0);
+ // fetcher は呼び出し側でインライン定義されることが多く毎レンダー identity が変わる。
+ // refresh / effect を安定させるため、実体は ref に逃がして常に最新を参照する。
+ const fetcherRef = useRef(fetcher);
+ fetcherRef.current = fetcher;
+
+ const refresh = useCallback(async () => {
+ const seq = ++requestSeqRef.current;
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await fetcherRef.current();
+ if (seq === requestSeqRef.current) setData(result);
+ } catch (e) {
+ if (seq === requestSeqRef.current) {
+ // Error でも message が空文字なら fallback を使う(message が無い場合は fallback という契約どおり)。
+ const message = e instanceof Error ? e.message : "";
+ setError(message || fallbackMessage);
+ }
+ } finally {
+ if (seq === requestSeqRef.current) setLoading(false);
+ }
+ }, [fallbackMessage]);
+
+ useEffect(() => {
+ // 無効化された瞬間に進行中リクエストを stale 化し(seq を進める)、後追いで解決した応答が
+ // state を上書きしないようにする。これで「enabled=false の間は取得しない」契約を
+ // true→false 遷移でも守る。
+ if (!enabled) {
+ requestSeqRef.current += 1;
+ setLoading(false);
+ return;
+ }
+ void refresh();
+ }, [enabled, refresh]);
+
+ return { data, loading, error, refresh };
+}
diff --git a/web/src/hooks/useBillingPage.ts b/web/src/hooks/useBillingPage.ts
index c6e1265f..041654e1 100644
--- a/web/src/hooks/useBillingPage.ts
+++ b/web/src/hooks/useBillingPage.ts
@@ -2,10 +2,9 @@
* トークン購入画面(ADR-0012)のデータ取得フック。
*
* 残高・購入パック・取引履歴をまとめて取得する。購入後は呼び出し側が refresh する。
+ * 取得ライフサイクル(loading / error / seq ガード)は useAsyncResource に委譲する。
*/
-import { useCallback, useEffect, useState } from "react";
-
import {
getCreditBalance,
getCreditPacks,
@@ -19,43 +18,45 @@ import type {
} from "../api/types";
import { FALLBACK_MESSAGES } from "../constants/messages";
import { PAID_REFERENCE_MODEL } from "../utils/creditEstimate";
+import { useAsyncResource } from "./useAsyncResource";
-export function useBillingPage() {
- const [balance, setBalance] = useState(null);
- const [packs, setPacks] = useState([]);
- const [transactions, setTransactions] = useState([]);
+/** 購入画面に必要なデータをまとめて取得した結果。 */
+type BillingPageData = {
+ balance: number | null;
+ packs: CreditPackResponse[];
+ transactions: CreditTransactionResponse[];
// 回数目安の基準: 有料モデル(Sonnet)の標準消費レート(null なら回数を出さない)
- const [paidRate, setPaidRate] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ paidRate: number | null;
+};
+
+const INITIAL_DATA: BillingPageData = {
+ balance: null,
+ packs: [],
+ transactions: [],
+ paidRate: null,
+};
- const refresh = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
+export function useBillingPage() {
+ const { data, loading, error, refresh } = useAsyncResource(
+ async () => {
const [balanceRes, packsRes, transactionsRes, ratesRes] = await Promise.all([
getCreditBalance(),
getCreditPacks(),
getCreditTransactions(),
getModelRates(),
]);
- setBalance(balanceRes.balance);
- setPacks(packsRes);
- setTransactions(transactionsRes);
const paid: ModelRateEntry | undefined = ratesRes.find(
(r) => r.model === PAID_REFERENCE_MODEL,
);
- setPaidRate(paid && !paid.is_free ? paid.baseline_credits_per_chat : null);
- } catch (e) {
- setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.CREDIT_BALANCE);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void refresh();
- }, [refresh]);
+ return {
+ balance: balanceRes.balance,
+ packs: packsRes,
+ transactions: transactionsRes,
+ paidRate: paid && !paid.is_free ? paid.baseline_credits_per_chat : null,
+ };
+ },
+ { initialData: INITIAL_DATA, fallbackMessage: FALLBACK_MESSAGES.CREDIT_BALANCE },
+ );
- return { balance, packs, transactions, paidRate, loading, error, refresh };
+ return { ...data, loading, error, refresh };
}
diff --git a/web/src/hooks/useCreditBalance.ts b/web/src/hooks/useCreditBalance.ts
index 6bce2089..13400026 100644
--- a/web/src/hooks/useCreditBalance.ts
+++ b/web/src/hooks/useCreditBalance.ts
@@ -3,41 +3,18 @@
*
* sonnet(有料モデル)選択時のみ取得する(enabled フラグ)。
* チャット送信後は呼び出し側が refresh() で最新残高に更新する。
+ * 取得ライフサイクル(loading / error / seq ガード)は useAsyncResource に委譲する。
*/
-import { useCallback, useEffect, useRef, useState } from "react";
-
import { getCreditBalance } from "../api/billing";
import { FALLBACK_MESSAGES } from "../constants/messages";
+import { useAsyncResource } from "./useAsyncResource";
export function useCreditBalance(enabled: boolean) {
- const [balance, setBalance] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- // refresh が重なったとき、古い応答が新しい状態を上書きしないよう最新だけ反映する
- const requestSeqRef = useRef(0);
-
- const refresh = useCallback(async () => {
- const seq = ++requestSeqRef.current;
- setLoading(true);
- setError(null);
- try {
- const response = await getCreditBalance();
- if (seq === requestSeqRef.current) setBalance(response.balance);
- } catch (e) {
- if (seq === requestSeqRef.current) {
- setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.CREDIT_BALANCE);
- }
- } finally {
- if (seq === requestSeqRef.current) setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- if (enabled) {
- void refresh();
- }
- }, [enabled, refresh]);
+ const { data: balance, loading, error, refresh } = useAsyncResource(
+ async () => (await getCreditBalance()).balance,
+ { enabled, initialData: null, fallbackMessage: FALLBACK_MESSAGES.CREDIT_BALANCE },
+ );
return { balance, loading, error, refresh };
}
diff --git a/web/src/hooks/useModelRates.ts b/web/src/hooks/useModelRates.ts
index c07fcc4a..8dcf144a 100644
--- a/web/src/hooks/useModelRates.ts
+++ b/web/src/hooks/useModelRates.ts
@@ -3,43 +3,30 @@
*
* 「Sonnet 約N回」の回数目安を出すために使う。利用実績のあるユーザーは
* 実測平均を優先し、本レート(ベースライン)は新規ユーザーのフォールバック。
+ * 取得ライフサイクルは useAsyncResource に委譲し、ここでは Map 化とアクセサだけを担う。
*/
-import { useCallback, useEffect, useState } from "react";
+import { useCallback } from "react";
import { getModelRates } from "../api/billing";
import type { AgentModelAlias, ModelRateEntry } from "../api/types";
import { FALLBACK_MESSAGES } from "../constants/messages";
+import { useAsyncResource } from "./useAsyncResource";
export type RatesByModel = Record;
export function useModelRates(enabled: boolean) {
- const [ratesByModel, setRatesByModel] = useState({});
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const refresh = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
+ const { data: ratesByModel, loading, error, refresh } = useAsyncResource(
+ async () => {
const entries = await getModelRates();
const map: RatesByModel = {};
for (const entry of entries) {
map[entry.model] = entry;
}
- setRatesByModel(map);
- } catch (e) {
- setError(e instanceof Error ? e.message : FALLBACK_MESSAGES.USAGE_SUMMARY);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- if (enabled) {
- void refresh();
- }
- }, [enabled, refresh]);
+ return map;
+ },
+ { enabled, initialData: {}, fallbackMessage: FALLBACK_MESSAGES.USAGE_SUMMARY },
+ );
/** 指定モデルの 1 回あたり標準消費クレジット(未取得・無料モデルは null)。 */
const getBaselineRate = useCallback(
diff --git a/web/src/test/factories/careerForm.ts b/web/src/test/factories/careerForm.ts
new file mode 100644
index 00000000..3ccf05b7
--- /dev/null
+++ b/web/src/test/factories/careerForm.ts
@@ -0,0 +1,56 @@
+/**
+ * テスト用の標準的な職務経歴フォーム(CareerFormState)を生成するファクトリ。
+ *
+ * 以前は careerDiff.test / useCareerDirty.test がそれぞれ同型のサンプルを直書きしており、
+ * 値が少しずつドリフトしていた(一方だけ role / technology_stacks.name を持つ等)。
+ * 正本をここへ集約し、各テストは overrides で必要な差分だけ与える。
+ *
+ * 毎回 structuredClone するため、呼び出し側で baseline / form を直接ミューテートしても
+ * テスト間で参照が共有されない。
+ */
+
+import {
+ blankCareerClient,
+ blankCareerExperience,
+ blankCareerProject,
+ blankCareerTechnologyStack,
+ blankResumeQualification,
+} from "../../constants";
+import type { CareerFormState } from "../../payloadBuilders";
+
+export function buildSampleCareerForm(
+ overrides: Partial = {},
+): CareerFormState {
+ return structuredClone({
+ full_name: "山田 太郎",
+ email: "yamada@example.com",
+ github_url: "",
+ career_summary: "サマリー",
+ self_pr: "自己PR",
+ experiences: [
+ {
+ ...blankCareerExperience,
+ company: "株式会社A",
+ business_description: "受託開発",
+ start_date: "2020-04",
+ clients: [
+ {
+ ...blankCareerClient,
+ projects: [
+ {
+ ...blankCareerProject,
+ name: "プロジェクトX",
+ role: "SE",
+ technology_stacks: [{ ...blankCareerTechnologyStack, name: "TypeScript" }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ qualifications: [
+ { ...blankResumeQualification, name: "基本情報", acquired_date: "2021-04-01" },
+ ],
+ ...overrides,
+ });
+}
diff --git a/web/src/utils/careerDiff.test.ts b/web/src/utils/careerDiff.test.ts
index dd6518d1..2a1adce3 100644
--- a/web/src/utils/careerDiff.test.ts
+++ b/web/src/utils/careerDiff.test.ts
@@ -1,49 +1,11 @@
import { describe, it, expect } from "vitest";
-import {
- blankCareerClient,
- blankCareerExperience,
- blankCareerProject,
- blankCareerTechnologyStack,
- blankResumeQualification,
-} from "../constants";
-import type { CareerFormState } from "../payloadBuilders";
+import { blankCareerExperience } from "../constants";
+import { buildSampleCareerForm } from "../test/factories/careerForm";
import { buildCareerChanges } from "./careerDiff";
/** ネストを含めて完全にコピーした form を作る(テスト間で参照を共有しないため)。 */
-function buildForm(): CareerFormState {
- return structuredClone({
- full_name: "山田 太郎",
- email: "yamada@example.com",
- github_url: "",
- career_summary: "サマリー",
- self_pr: "自己PR",
- experiences: [
- {
- ...blankCareerExperience,
- company: "株式会社A",
- business_description: "受託開発",
- start_date: "2020-04",
- clients: [
- {
- ...blankCareerClient,
- projects: [
- {
- ...blankCareerProject,
- name: "プロジェクトX",
- role: "SE",
- technology_stacks: [{ ...blankCareerTechnologyStack, name: "TypeScript" }],
- },
- ],
- },
- ],
- },
- ],
- qualifications: [
- { ...blankResumeQualification, name: "基本情報", acquired_date: "2021-04-01" },
- ],
- });
-}
+const buildForm = buildSampleCareerForm;
describe("buildCareerChanges", () => {
it("変更が無ければ空配列を返す", () => {