{"record":{"id":"eb1fef85318f0580","repo":"star7th/showdoc","slug":"ai","errorCode":null,"errorMessage":"AI 生成失败","messagePattern":"AI 生成失败","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web_src/src/views/modals/page/AIModal/index.vue","lineNumber":163,"sourceCode":"\n    // 获取用户 token\n    const userInfo = getUserInfoFromStorage()\n    if (userInfo && userInfo.user_token) {\n      jsonBody.user_token = userInfo.user_token\n    }\n\n    // 使用 fetch 实现流式响应\n    const url = getServerHost() + '/api/ai/create'\n    const response = await fetch(url, {\n      method: 'POST',\n      body: new URLSearchParams(jsonBody),\n      headers: {\n        'Content-Type': 'application/x-www-form-urlencoded'\n      }\n    })\n\n    if (!response.ok) {\n      throw new Error('AI 生成失败')\n    }\n\n    const reader = response.body?.getReader()\n    const decoder = new TextDecoder('utf-8')\n    let result = ''\n\n    if (reader) {\n      const readChunk = async () => {\n        const { value, done } = await reader.read()\n\n        if (!done) {\n          const dataString = decoder.decode(value)\n          const lines = dataString.trim().split('data: ')\n\n          for (const line of lines) {\n            if (line.trim() !== '') {\n              try {\n                const data = JSON.parse(line.replace('data: ', ''))","sourceCodeStart":145,"sourceCodeEnd":181,"githubUrl":"https://github.com/star7th/showdoc/blob/6a3fa91eee5ebf36ba9d9cba17e0fea6dfd4bb89/web_src/src/views/modals/page/AIModal/index.vue#L145-L181","documentation":"Generic message thrown by the AI-generation modal (handleGenerate) when the POST to {serverHost}/api/ai/create (form-urlencoded body with content + user_token) returns a non-OK status. The real status is discarded, so 'AI 生成失败' ('AI generation failed') covers everything from auth rejection to a PHP 500. Server-side, AiController::create() first calls requireLoginUser(); note that a missing admin open_api_key is NOT this error — that case returns 200 with an SSE-formatted error stream.","triggerScenarios":"user_token missing (getUserInfoFromStorage() returned nothing) or expired -> requireLoginUser() rejects with 401/403; getServerHost() misconfigured or server rolled back -> 404; PHP fatal/bootstrap RuntimeException (DB unavailable) -> 500; reverse-proxy failure -> 502/504; logged-in user whose session was invalidated server-side but still present in localStorage.","commonSituations":"User pastes localStorage/cookie data from another environment so user_token does not match the server; server upgraded and the /api/ai route namespace changed; SQLite file unreadable making every request 500; admin assumes this error means the AI key is missing, but that configuration mistake actually arrives as a 200 SSE error message ('管理员没有在管理后台配置AI助手认证KEY...') rendered into the output area instead.","solutions":["Include the status in the thrown error (`throw new Error(`AI 生成失败 (HTTP ${response.status})`)`) and branch on it: 401/403 -> re-login, 404 -> check server host/route, 5xx -> check server logs.","Verify getUserInfoFromStorage() returned a fresh user_token before fetching; if absent, force login instead of posting without credentials.","Confirm the server actually serves /api/ai/create on the host returned by getServerHost() (curl the endpoint with the same form body).","If 5xx, inspect the PHP server log — the same request also triggers server/app/Common/bootstrap.php, whose DB failure produces 500s for every API call.","For streaming errors that DO arrive with status 200, parse the SSE `data:` line containing error_code/error_message and surface that text instead of this generic throw."],"exampleFix":"// before\nif (!response.ok) {\n  throw new Error('AI 生成失败')\n}\n\n// after\nif (!response.ok) {\n  const detail = await response.text().catch(() => '')\n  throw new Error(`AI 生成失败 (HTTP ${response.status}${response.statusText ? ' ' + response.statusText : ''}) ${detail.slice(0, 120)}`.trim())\n}","handlingStrategy":"try-catch","validationCode":"const userInfo = getUserInfoFromStorage()\nif (!userInfo?.user_token) {\n  outputContent.value = ''\n  generating.value = false\n  // prompt login instead of firing a doomed request\n  throw new Error('请先登录后再使用 AI 生成')\n}","typeGuard":"function isAiGenerateFailure(e: unknown): e is Error {\n  return e instanceof Error && e.message.startsWith('AI 生成失败')\n}","tryCatchPattern":"try {\n  const response = await fetch(url, { /* ... */ })\n  if (!response.ok) {\n    if (response.status === 401 || response.status === 403) throw new Error('登录已过期，请重新登录后再试')\n    throw new Error(`AI 生成失败 (HTTP ${response.status})`)\n  }\n  // ...\n} catch (e) {\n  generating.value = false\n  outputContent.value = e instanceof Error ? e.message : 'AI 生成失败'\n}","preventionTips":["Never discard response.status — embed it in the error so 401 vs 500 is distinguishable in bug reports.","Gate the generate button on a verified logged-in state (userInfo.user_token present).","Remember config errors (missing admin AI key, project AI disabled) arrive as HTTP 200 SSE error payloads — parse data: lines for error_code/error_message rather than relying on !response.ok alone.","In tests, mock fetch with realistic Response(url-encoded body, status) objects that include a body stream."],"tags":["ai-generation","http-status","fetch","vue","sse"],"backgroundTag":"http-error-status","analyzedSha":"6a3fa91eee5ebf36ba9d9cba17e0fea6dfd4bb89","analyzedAt":"2026-08-21T01:16:20.916Z","schemaVersion":2},"datasetVersion":"2026-08-21T03:17:12.404Z"}