Просмотр исходного кода

feat(chat): 增加上下文用量圆环,并按落盘状态折叠整章历史

在输入区展示动态上下文占用,悬停分层明细;运行中纳入工具回读与 usage 校准。已入库章节正文改为指针注入,未保存仍全量保留以便继续修改。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 месяц назад
Родитель
Сommit
be76596a47

+ 2 - 1
src/components/chat/chat-message.tsx

@@ -63,6 +63,7 @@ interface ChatMessageProps {
   novelMode?: boolean;
   projectPath?: string | null;
   onSaveAsChapter?: (
+    messageId: string,
     content: string,
     toolCalls?: Array<{ name: string; result?: string; status?: string }>,
   ) => void;
@@ -221,7 +222,7 @@ export function ChatMessage({
               <button
                 type="button"
                 onClick={() =>
-                  onSaveAsChapter(message.content, message.agentToolCalls)
+                  onSaveAsChapter(message.id, message.content, message.agentToolCalls)
                 }
                 disabled={isSaving}
                 className="rounded border border-border px-2 py-0.5 text-[11px] text-foreground hover:bg-accent disabled:opacity-50"

+ 103 - 11
src/components/chat/chat-panel.tsx

@@ -1,4 +1,4 @@
-import { useRef, useEffect, useCallback, useState, useMemo, type CSSProperties } from "react"
+import { useRef, useEffect, useCallback, useState, useMemo, useDeferredValue, type CSSProperties } from "react"
 import { createPortal } from "react-dom"
 import { useTranslation } from "react-i18next"
 import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, ChevronDown, Check, History, ArrowDown } from "lucide-react"
@@ -140,6 +140,15 @@ import {
   type ContextHubResult,
   type ContextIntent,
 } from "@/lib/context-hub"
+import { buildAgentHistoryMessages } from "@/lib/context-hub/chapter-body-injection"
+import {
+  buildContextUsageSnapshot,
+  calibrateContextUsageSnapshot,
+  composeLiveContextUsage,
+} from "@/lib/context-usage"
+import { toOpenAITools } from "@/lib/agent/tools-schema"
+import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
+import { ContextUsageRing } from "@/components/chat/context-usage-ring"
 import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service"
 import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service"
 
@@ -995,6 +1004,43 @@ export function ChatPanel() {
     writingSkills: agentUserWritingSkills,
     mcpCapabilities: agentMcpCapabilities,
   } = useAgentConfig(agentSystemPrompt)
+  const deferredReferenceText = useDeferredValue(referenceText)
+  const liveContextUsage = useMemo(() => {
+    const historyMessages = selectContextHistoryMessages(
+      activeMessages.filter((message) => (
+        (message.role === "user" || message.role === "assistant")
+        && !message.discarded
+        && !message.isAgentRunning
+      )),
+      activeConversation?.contextSummary?.text,
+    )
+    const measuredAt = activeConversation?.lastContextUsage?.measuredAt ?? 0
+    const pendingToolResultTexts = activeMessages
+      .filter((message) => message.isAgentRunning)
+      .flatMap((message) => message.agentToolCalls ?? [])
+      .filter((call) => (
+        call.status === "done"
+        && typeof call.result === "string"
+        && call.result.trim().length > 0
+        && (call.finishedAt ?? 0) > measuredAt
+      ))
+      .map((call) => call.result)
+    return composeLiveContextUsage(activeConversation?.lastContextUsage, {
+      windowTokens: agentConfig?.llmConfig
+        ? getEffectiveMaxContextSize(agentConfig.llmConfig)
+        : undefined,
+      sessionSummaryText: activeConversation?.contextSummary?.text ?? "",
+      historyTexts: historyMessages.map((message) => message.content),
+      currentInput: deferredReferenceText,
+      pendingToolResultTexts,
+    })
+  }, [
+    activeConversation?.contextSummary?.text,
+    activeConversation?.lastContextUsage,
+    activeMessages,
+    agentConfig?.llmConfig,
+    deferredReferenceText,
+  ])
   const runChapterPlanSelfCheck = useCallback(async (planContent: string, contextPack?: ContextPack | null) => {
     const trimmedPlan = planContent.trim()
     if (!trimmedPlan) {
@@ -1113,6 +1159,7 @@ export function ChatPanel() {
   }, [closeChapterPlanDialog])
 
   const handleSaveAsChapter = useCallback(async (
+    messageId: string,
     content: string,
     toolCalls?: CopyableToolCall[],
   ) => {
@@ -1176,6 +1223,11 @@ export function ChatPanel() {
       const chapterPath = `${chapterDir}/chapter-${String(targetChapterNumber).padStart(3, "0")}.md`
       const chapterMarkdown = buildDraftContent(targetChapterNumber, chapterTitle, cleanedContent)
       await writeFile(chapterPath, chapterMarkdown)
+      useChatStore.getState().setMessageChapterRef(messageId, {
+        chapterNumber: targetChapterNumber,
+        path: chapterPath,
+        savedAt: Date.now(),
+      })
       setChapterSaveStatus(`已保存为${chapterTitle}`)
       useWikiStore.getState().setSelectedFile(chapterPath)
       useWikiStore.getState().setFileContent(chapterMarkdown)
@@ -1740,18 +1792,20 @@ export function ChatPanel() {
       const userContent = !effectiveDeAiSkill && deAiMode
         ? injectDeAiDirective(rawUserContent, deAiMode)
         : rawUserContent
+      const readChapterToolAvailable = !prePluginResult?.enabledToolNames
+        || prePluginResult.enabledToolNames.includes("read_chapter")
+      const historyForModel = selectContextHistoryMessages(
+        activeConvMessages,
+        contextHubResult?.sessionSummary,
+      )
+      const historyMessages = await buildAgentHistoryMessages(historyForModel, {
+        projectPath: pp,
+        novelMode,
+        readChapterToolAvailable,
+      })
       const agentMessages: AgentMessage[] = [
         { role: "system", content: contextHubSystemContent ?? effectiveSystemPrompt },
-        ...selectContextHistoryMessages(
-          activeConvMessages,
-          contextHubResult?.sessionSummary,
-        ).map((message) => ({
-          role: message.role,
-          content: message.content,
-          ...(message.reasoning_content !== undefined
-            ? { reasoning_content: message.reasoning_content }
-            : {}),
-        } satisfies AgentMessage)),
+        ...historyMessages,
         { role: "user", content: userContent },
       ]
       const sessionRegistry = new ToolRegistry()
@@ -1812,6 +1866,27 @@ export function ChatPanel() {
               (tool) => tool.name !== "run_chapter_workflow" && tool.category !== "write",
             )
           : agentConfig.tools
+        const advertisedTools = prePluginResult?.enabledToolNames
+          ? sessionTools.filter((tool) => prePluginResult.enabledToolNames!.includes(tool.name))
+          : sessionTools
+        const usageSnapshotBase = buildContextUsageSnapshot({
+          windowTokens: getEffectiveMaxContextSize(agentConfig.llmConfig),
+          softwareRules: contextHubResult ? contextHubSoftwareRules : systemPromptForConfig,
+          toolDefinitionsJson: JSON.stringify(toOpenAITools(advertisedTools)),
+          stableTokens: contextHubResult?.stats.stableTokens,
+          summaryTokens: contextHubResult?.stats.summaryTokens,
+          dynamicTokens: contextHubResult?.stats.dynamicTokens,
+          historyTexts: historyMessages.map((message) => (
+            typeof message.content === "string"
+              ? message.content
+              : message.content.map((block) => block.type === "text" ? block.text : "").join("")
+          )),
+          currentInput: typeof userContent === "string"
+            ? userContent
+            : userContent.map((block) => block.type === "text" ? block.text : "").join(""),
+        })
+        // Seed this turn's baseline so the ring can grow with tool reads before the first usage report.
+        useChatStore.getState().setConversationContextUsage(capturedConvId, usageSnapshotBase)
         const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
           userMessage: plainText,
           projectPath,
@@ -1864,6 +1939,13 @@ export function ChatPanel() {
                   agentStages: applyAgentActivityEvent(message.agentStages, event),
                 }))
               },
+              onUsage: (usage) => {
+                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+                useChatStore.getState().setConversationContextUsage(
+                  capturedConvId,
+                  calibrateContextUsageSnapshot(usageSnapshotBase, usage),
+                )
+              },
               onDone: () => {
               if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
               const finalContent = useChatStore.getState().streamingContents[capturedConvId] ?? ""
@@ -1884,6 +1966,10 @@ export function ChatPanel() {
 
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+        useChatStore.getState().setConversationContextUsage(
+          capturedConvId,
+          calibrateContextUsageSnapshot(usageSnapshotBase, record.usage),
+        )
         if (contextHubResult && record.usage) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
@@ -2554,6 +2640,12 @@ export function ChatPanel() {
               submitDisabled={concurrencyFull}
               submitDisabledReason={concurrencyFull ? concurrencyLimitReason : undefined}
               onStop={handleStop}
+              leftFooterControls={
+                <ContextUsageRing
+                  usage={liveContextUsage}
+                  onCreateConversation={() => createConversation()}
+                />
+              }
               rightControls={
                 <ChatModelSelector
                   value={aiChatModel}

+ 109 - 0
src/components/chat/context-usage-ring.spec.tsx

@@ -0,0 +1,109 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ContextUsageRing } from "./context-usage-ring"
+import type { ContextUsageSnapshot } from "@/lib/context-usage"
+
+vi.mock("react-i18next", () => ({
+  useTranslation: () => ({
+    t: (key: string, options?: { percent?: number }) => {
+      if (key === "chat.contextUsage.percentFull") return `${options?.percent ?? 0}% Full`
+      if (key.startsWith("chat.contextUsage.segments.")) {
+        return key.replace("chat.contextUsage.segments.", "")
+      }
+      if (key === "chat.contextUsage.title") return "Context Usage"
+      if (key === "chat.contextUsage.tokens") return "Tokens"
+      if (key === "chat.contextUsage.fullHint") return "Context is nearly full"
+      if (key === "chat.contextUsage.newConversation") return "New conversation"
+      return key
+    },
+  }),
+}))
+
+const usage: ContextUsageSnapshot = {
+  windowTokens: 1000,
+  totalTokens: 230,
+  measuredAt: 1,
+  estimated: true,
+  segments: [
+    { key: "softwareRules", tokens: 50 },
+    { key: "toolDefinitions", tokens: 30 },
+    { key: "stableCore", tokens: 40 },
+    { key: "sessionSummary", tokens: 20 },
+    { key: "dynamicContext", tokens: 30 },
+    { key: "history", tokens: 40 },
+    { key: "currentInput", tokens: 20 },
+  ],
+}
+
+describe("ContextUsageRing", () => {
+  let container: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    container = document.createElement("div")
+    document.body.appendChild(container)
+    root = createRoot(container)
+  })
+
+  afterEach(async () => {
+    await act(async () => {
+      root.unmount()
+    })
+    container.remove()
+  })
+
+  it("renders nothing without usage", async () => {
+    await act(async () => {
+      root.render(<ContextUsageRing />)
+    })
+    expect(container.textContent).toBe("")
+  })
+
+  it("shows percent and warns when nearly full", async () => {
+    const onCreateConversation = vi.fn()
+    const fullUsage: ContextUsageSnapshot = {
+      ...usage,
+      totalTokens: 950,
+      segments: [{ key: "history", tokens: 950 }],
+    }
+    await act(async () => {
+      root.render(
+        <ContextUsageRing
+          usage={fullUsage}
+          onCreateConversation={onCreateConversation}
+        />,
+      )
+    })
+
+    const trigger = container.querySelector("button")
+    expect(trigger).toBeTruthy()
+    expect(trigger?.textContent).toContain("95")
+
+    await act(async () => {
+      trigger?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
+      trigger?.focus()
+      trigger?.click()
+    })
+
+    // Tooltip content may render in a portal; look across document.
+    await act(async () => {
+      await Promise.resolve()
+    })
+    const hint = Array.from(document.querySelectorAll("p")).find((node) =>
+      node.textContent?.includes("Context is nearly full"),
+    )
+    const createButton = Array.from(document.querySelectorAll("button")).find((node) =>
+      node.textContent?.includes("New conversation"),
+    )
+    expect(hint || createButton).toBeTruthy()
+    if (createButton) {
+      await act(async () => {
+        createButton.click()
+      })
+      expect(onCreateConversation).toHaveBeenCalled()
+    }
+  })
+})

+ 206 - 0
src/components/chat/context-usage-ring.tsx

@@ -0,0 +1,206 @@
+import { useMemo } from "react"
+import { useTranslation } from "react-i18next"
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+  CONTEXT_USAGE_FULL_RATIO,
+  CONTEXT_USAGE_SEGMENT_ORDER,
+  CONTEXT_USAGE_WARN_RATIO,
+  contextUsageRatio,
+  formatContextTokenCount,
+  type ContextUsageKey,
+  type ContextUsageSnapshot,
+} from "@/lib/context-usage"
+import { cn } from "@/lib/utils"
+
+const SEGMENT_COLORS: Record<ContextUsageKey, string> = {
+  softwareRules: "#94a3b8",
+  toolDefinitions: "#c4b5fd",
+  stableCore: "#4ade80",
+  sessionSummary: "#facc15",
+  dynamicContext: "#c084fc",
+  history: "#60a5fa",
+  toolResults: "#2dd4bf",
+  currentInput: "#fb923c",
+}
+
+export interface ContextUsageRingProps {
+  usage?: ContextUsageSnapshot | null
+  onCreateConversation?: () => void
+  className?: string
+}
+
+function ringStrokeColor(ratio: number): string {
+  if (ratio >= CONTEXT_USAGE_FULL_RATIO) return "#ef4444"
+  if (ratio >= CONTEXT_USAGE_WARN_RATIO) return "#f59e0b"
+  return "#22c55e"
+}
+
+function SegmentBar({ segments, totalTokens }: {
+  segments: ContextUsageSnapshot["segments"]
+  totalTokens: number
+}) {
+  const ordered = CONTEXT_USAGE_SEGMENT_ORDER
+    .map((key) => segments.find((segment) => segment.key === key))
+    .filter((segment): segment is NonNullable<typeof segment> => Boolean(segment && segment.tokens > 0))
+  const denominator = Math.max(1, totalTokens)
+  return (
+    <div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
+      {ordered.map((segment) => (
+        <div
+          key={segment.key}
+          className="h-full"
+          style={{
+            width: `${Math.max(1, (segment.tokens / denominator) * 100)}%`,
+            backgroundColor: SEGMENT_COLORS[segment.key] ?? "#94a3b8",
+          }}
+        />
+      ))}
+    </div>
+  )
+}
+
+export function ContextUsageRing({
+  usage,
+  onCreateConversation,
+  className,
+}: ContextUsageRingProps) {
+  const { t } = useTranslation()
+  const ratio = usage ? contextUsageRatio(usage) : 0
+  const percent = Math.round(ratio * 100)
+  const stroke = ringStrokeColor(ratio)
+  const isFull = ratio >= CONTEXT_USAGE_FULL_RATIO
+  const size = 22
+  const strokeWidth = 3
+  const radius = (size - strokeWidth) / 2
+  const circumference = 2 * Math.PI * radius
+  const dashOffset = circumference * (1 - Math.min(1, ratio))
+
+  const segmentRows = useMemo(() => {
+    if (!usage) return []
+    const byKey = new Map(usage.segments.map((segment) => [segment.key, segment.tokens]))
+    return CONTEXT_USAGE_SEGMENT_ORDER
+      .map((key) => ({ key, tokens: byKey.get(key) ?? 0 }))
+      .filter((row) => row.tokens > 0)
+  }, [usage])
+
+  if (!usage) return null
+
+  return (
+    <TooltipProvider delay={150}>
+      <Tooltip>
+        <TooltipTrigger
+          type="button"
+          className={cn(
+            "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
+            isFull && "text-destructive",
+            className,
+          )}
+          aria-label={t("chat.contextUsage.title")}
+        >
+          <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
+            <circle
+              cx={size / 2}
+              cy={size / 2}
+              r={radius}
+              fill="none"
+              stroke="currentColor"
+              strokeOpacity={0.2}
+              strokeWidth={strokeWidth}
+            />
+            <circle
+              cx={size / 2}
+              cy={size / 2}
+              r={radius}
+              fill="none"
+              stroke={stroke}
+              strokeWidth={strokeWidth}
+              strokeLinecap="round"
+              strokeDasharray={circumference}
+              strokeDashoffset={dashOffset}
+              transform={`rotate(-90 ${size / 2} ${size / 2})`}
+            />
+            <text
+              x="50%"
+              y="50%"
+              dominantBaseline="central"
+              textAnchor="middle"
+              fontSize="7"
+              fill="currentColor"
+            >
+              {percent}
+            </text>
+          </svg>
+        </TooltipTrigger>
+        <TooltipContent
+          side="top"
+          align="start"
+          className="w-72 max-w-none border border-border bg-popover p-3 text-popover-foreground shadow-md"
+        >
+          <div className="space-y-2.5 text-left">
+            <div className="flex items-baseline justify-between gap-3">
+              <div>
+                <div className="text-xs font-medium">{t("chat.contextUsage.title")}</div>
+                <div className="mt-0.5 text-sm font-semibold">
+                  {t("chat.contextUsage.percentFull", { percent })}
+                </div>
+              </div>
+              <div className="text-xs text-muted-foreground">
+                {usage.estimated ? "~" : ""}
+                {formatContextTokenCount(usage.totalTokens)}
+                {" / "}
+                {formatContextTokenCount(usage.windowTokens)}
+                {" "}
+                {t("chat.contextUsage.tokens")}
+              </div>
+            </div>
+
+            <SegmentBar segments={usage.segments} totalTokens={usage.totalTokens} />
+
+            <div className="space-y-1.5">
+              {segmentRows.map((row) => (
+                <div key={row.key} className="flex items-center justify-between gap-2 text-xs">
+                  <div className="flex min-w-0 items-center gap-2">
+                    <span
+                      className="h-2.5 w-2.5 shrink-0 rounded-[3px]"
+                      style={{ backgroundColor: SEGMENT_COLORS[row.key] }}
+                    />
+                    <span className="truncate">{t(`chat.contextUsage.segments.${row.key}`)}</span>
+                  </div>
+                  <span className="shrink-0 tabular-nums text-muted-foreground">
+                    {formatContextTokenCount(row.tokens)}
+                  </span>
+                </div>
+              ))}
+            </div>
+
+            {isFull && (
+              <div className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-2">
+                <p className="text-xs text-destructive">
+                  {t("chat.contextUsage.fullHint")}
+                </p>
+                {onCreateConversation && (
+                  <button
+                    type="button"
+                    className="rounded border border-destructive/40 px-2 py-1 text-[11px] text-destructive hover:bg-destructive/10"
+                    onClick={(event) => {
+                      event.preventDefault()
+                      event.stopPropagation()
+                      onCreateConversation()
+                    }}
+                  >
+                    {t("chat.contextUsage.newConversation")}
+                  </button>
+                )}
+              </div>
+            )}
+          </div>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  )
+}

+ 102 - 5
src/components/sources/outline-chat-panel.tsx

@@ -5,6 +5,7 @@ import {
   useEffect,
   useMemo,
   useState,
+  useDeferredValue,
 } from "react";
 import { createPortal } from "react-dom";
 import {
@@ -119,10 +120,12 @@ import {
   type OutlineBudgetStage,
 } from "@/lib/context-budget";
 import {
+  getEffectiveMaxContextSize,
   getEffectiveMaxOutputTokens,
   thinkingMinMaxTokens,
 } from "@/lib/llm-providers";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
+import { ContextUsageRing } from "@/components/chat/context-usage-ring";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
 import { StreamingMarkdown } from "@/components/common/streaming-markdown";
@@ -183,6 +186,12 @@ import {
   type ContextHubResult,
   type ContextHubSnapshotRef,
 } from "@/lib/context-hub";
+import {
+  buildContextUsageSnapshot,
+  calibrateContextUsageSnapshot,
+  composeLiveContextUsage,
+} from "@/lib/context-usage";
+import { selectContextHistoryMessages } from "@/lib/context-hub/session-summary";
 import { addLlmUsage, type LlmUsage } from "@/lib/llm-usage";
 import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service";
 import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service";
@@ -243,6 +252,37 @@ function mergeDisabledTools(...groups: Array<readonly string[] | undefined>): st
   return Array.from(new Set(groups.flatMap((group) => group ?? [])));
 }
 
+function messageContentToText(content: AgentMessage["content"]): string {
+  if (typeof content === "string") return content;
+  return content.map((block) => (block.type === "text" ? block.text : "")).join("");
+}
+
+function persistOutlineConversationContextUsage(input: {
+  conversationId: string
+  windowTokens: number
+  systemPrompt: string
+  contextHubResult?: ContextHubResult | null
+  historyMessages?: Array<{ content: string }>
+  currentInput?: string
+  usage?: LlmUsage
+}): void {
+  useOutlineChatStore.getState().setConversationContextUsage(
+    input.conversationId,
+    calibrateContextUsageSnapshot(
+      buildContextUsageSnapshot({
+        windowTokens: input.windowTokens,
+        softwareRules: input.systemPrompt,
+        stableTokens: input.contextHubResult?.stats.stableTokens,
+        summaryTokens: input.contextHubResult?.stats.summaryTokens,
+        dynamicTokens: input.contextHubResult?.stats.dynamicTokens,
+        historyTexts: (input.historyMessages ?? []).map((message) => message.content),
+        currentInput: input.currentInput,
+      }),
+      input.usage,
+    ),
+  );
+}
+
 const OUTLINE_CHAT_SKILL_ROUTES = [
   "outline",
   "setting",
@@ -1222,6 +1262,25 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const effectiveOutlineModelId = storedOutlineModelId || fallbackOutlineModelId;
 
   const [inputValue, setInputValue] = useState("");
+  const deferredInputValue = useDeferredValue(inputValue);
+  const liveContextUsage = useMemo(() => {
+    const historyMessages = selectContextHistoryMessages(
+      activeMessages.filter((message) => message.role === "user" || message.role === "assistant"),
+      activeConv?.contextSummary?.text,
+    );
+    return composeLiveContextUsage(activeConv?.lastContextUsage, {
+      windowTokens: getEffectiveMaxContextSize(llmConfig),
+      sessionSummaryText: activeConv?.contextSummary?.text ?? "",
+      historyTexts: historyMessages.map((message) => message.content),
+      currentInput: deferredInputValue,
+    });
+  }, [
+    activeConv?.contextSummary?.text,
+    activeConv?.lastContextUsage,
+    activeMessages,
+    deferredInputValue,
+    llmConfig,
+  ]);
   const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<
     ReferenceToken[]
   >([]);
@@ -2535,6 +2594,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             console.warn("AI 大纲供应商缓存用量快照保存失败,继续保留本地缓存统计:", error);
           }
         }
+        {
+          const userMessage = agentMessages.find((message) => message.role === "user");
+          persistOutlineConversationContextUsage({
+            conversationId: convId,
+            windowTokens: getEffectiveMaxContextSize(effectiveLlmConfig),
+            systemPrompt: contextHubResult ? baseSystemPrompt : systemPrompt,
+            contextHubResult,
+            historyMessages: historyPlan.messages.map((message) => ({
+              content: messageContentToText(message.content),
+            })),
+            currentInput: userMessage ? messageContentToText(userMessage.content) : prompt,
+            usage: providerUsage,
+          });
+        }
 
         const finalSources = Array.from(
           new Set([
@@ -3111,6 +3184,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             console.warn("AI 大纲续传供应商缓存用量快照保存失败,继续保留本地缓存统计:", error);
           }
         }
+        persistOutlineConversationContextUsage({
+          conversationId: capturedConvId,
+          windowTokens: getEffectiveMaxContextSize(effectiveLlmConfig),
+          systemPrompt: contextHubResult ? baseSystemPrompt : systemPrompt,
+          contextHubResult,
+          usage: providerUsage,
+        });
 
         // 更新最终状态
         updateOutlineMultiAgentRun(capturedConvId, messageId, (run) => {
@@ -3477,6 +3557,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             console.warn("AI 大纲重新生成供应商缓存用量快照保存失败,继续保留本地缓存统计:", error);
           }
         }
+        persistOutlineConversationContextUsage({
+          conversationId: capturedConvId,
+          windowTokens: getEffectiveMaxContextSize(effectiveLlmConfig),
+          systemPrompt: contextHubResult ? baseSystemPrompt : systemPrompt,
+          contextHubResult,
+          historyMessages: historyMessages.map((message) => ({
+            content: messageContentToText(message.content),
+          })),
+          currentInput: lastUserRequest,
+          usage: record.usage,
+        });
 
         const sources = [
           ...outlineToolCallsToSources(record.toolCalls),
@@ -4085,12 +4176,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           onAtTrigger={() => setReferencePickerOpen(true)}
           insertTokensRef={insertReferenceTokensRef}
           leftFooterControls={
-            <TooltipProvider delay={200}>
-              <OutlineGenerationMenu
-                disabled={submitDisabled}
-                onGenerate={handleGenerateSection}
+            <>
+              <ContextUsageRing
+                usage={liveContextUsage}
+                onCreateConversation={() => createConversation()}
               />
-            </TooltipProvider>
+              <TooltipProvider delay={200}>
+                <OutlineGenerationMenu
+                  disabled={submitDisabled}
+                  onGenerate={handleGenerateSection}
+                />
+              </TooltipProvider>
+            </>
           }
           rightControls={
             hasAvailableModels ? (

+ 1 - 1
src/components/ui/tooltip.tsx

@@ -56,7 +56,7 @@ function TooltipContent({
           {...props}
         >
           {children}
-          <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
+          <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-inherit fill-inherit data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
         </TooltipPrimitive.Popup>
       </TooltipPrimitive.Positioner>
     </TooltipPrimitive.Portal>

+ 18 - 1
src/i18n/en.json

@@ -391,7 +391,24 @@
     "saveAsDraft": "Save as draft",
     "selectModel": "Select model",
     "searchModel": "Search models...",
-    "noModelFound": "No matching model"
+    "noModelFound": "No matching model",
+    "contextUsage": {
+      "title": "Context Usage",
+      "percentFull": "{{percent}}% Full",
+      "tokens": "Tokens",
+      "fullHint": "Context is nearly full. Start a new conversation to keep project rules and writing quality.",
+      "newConversation": "New conversation",
+      "segments": {
+        "softwareRules": "System prompt",
+        "toolDefinitions": "Tool definitions",
+        "stableCore": "Stable project core",
+        "sessionSummary": "Session summary",
+        "dynamicContext": "Dynamic context",
+        "history": "Conversation",
+        "toolResults": "Tool results",
+        "currentInput": "Current input"
+      }
+    }
   },
   "search": {
     "title": "Search",

+ 18 - 1
src/i18n/zh.json

@@ -262,7 +262,24 @@
     "typeAMessage": "输入消息...",
     "selectModel": "选择模型",
     "searchModel": "搜索模型...",
-    "noModelFound": "未找到匹配的模型"
+    "noModelFound": "未找到匹配的模型",
+    "contextUsage": {
+      "title": "上下文用量",
+      "percentFull": "{{percent}}% 已占用",
+      "tokens": "Tokens",
+      "fullHint": "上下文接近上限,建议新建对话以保持项目设定与写作质量。",
+      "newConversation": "新建对话",
+      "segments": {
+        "softwareRules": "系统规则",
+        "toolDefinitions": "工具定义",
+        "stableCore": "项目稳定核心",
+        "sessionSummary": "会话摘要",
+        "dynamicContext": "本轮动态上下文",
+        "history": "对话历史",
+        "toolResults": "工具回读",
+        "currentInput": "当前输入"
+      }
+    }
   },
   "search": {
     "title": "搜索",

+ 2 - 0
src/lib/agent/ai-chat-session.ts

@@ -8,6 +8,7 @@ export interface RunAiChatSessionCallbacks {
   onReasoningToken?: (chunk: string) => void
   onToolEvent?: AgentRunCallbacks["onToolEvent"]
   onActivityEvent?: AgentRunCallbacks["onActivityEvent"]
+  onUsage?: AgentRunCallbacks["onUsage"]
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -45,6 +46,7 @@ export async function runAiChatSession(input: RunAiChatSessionInput): Promise<Ag
       onToolError: () => {},
       onToolEvent: input.callbacks.onToolEvent,
       onActivityEvent: input.callbacks.onActivityEvent,
+      onUsage: input.callbacks.onUsage,
       onDone: input.callbacks.onDone,
       onError: input.callbacks.onError,
     },

+ 1 - 0
src/lib/agent/runner.ts

@@ -142,6 +142,7 @@ export class AgentRunner {
         },
         onUsage: (usage) => {
           record.usage = addLlmUsage(record.usage, usage)
+          callbacks.onUsage?.(record.usage)
         },
         onDone: () => {
           // stream finished

+ 2 - 0
src/lib/agent/types.ts

@@ -134,6 +134,8 @@ export interface AgentRunCallbacks {
   onToolError: (callId: string, error: string) => void
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
+  /** Cumulative prompt/usage so far across agent rounds. */
+  onUsage?: (usage: LlmUsage) => void
   onDone: () => void
   onError: (error: Error) => void
 }

+ 154 - 0
src/lib/context-hub/chapter-body-injection.spec.ts

@@ -0,0 +1,154 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const fileExistsMock = vi.fn()
+const findChapterFileByNumberMock = vi.fn()
+const loadSnapshotMock = vi.fn()
+
+vi.mock("@/commands/fs", () => ({
+  fileExists: (...args: unknown[]) => fileExistsMock(...args),
+}))
+
+vi.mock("@/lib/novel/chapter-utils", async () => {
+  const actual = await vi.importActual<typeof import("@/lib/novel/chapter-utils")>("@/lib/novel/chapter-utils")
+  return {
+    ...actual,
+    findChapterFileByNumber: (...args: unknown[]) => findChapterFileByNumberMock(...args),
+  }
+})
+
+vi.mock("@/lib/novel/chapter-ingest", () => ({
+  loadSnapshot: (...args: unknown[]) => loadSnapshotMock(...args),
+}))
+
+import {
+  buildFoldedChapterPointer,
+  buildHistoryContentForModel,
+  CHAPTER_BODY_FOLD_MIN_CHARS,
+  isFoldableChapterBody,
+  resolveChapterNumberFromMessage,
+} from "./chapter-body-injection"
+
+const longBody = `${"正文内容。".repeat(400)}\n第12章完`
+const projectPath = "/Novel/Demo"
+
+describe("chapter body injection", () => {
+  beforeEach(() => {
+    fileExistsMock.mockReset()
+    findChapterFileByNumberMock.mockReset()
+    loadSnapshotMock.mockReset()
+  })
+
+  it("rejects short bodies and chapter_plan messages", () => {
+    expect(isFoldableChapterBody("短文")).toBe(false)
+    expect(isFoldableChapterBody(`${"x".repeat(CHAPTER_BODY_FOLD_MIN_CHARS)}\n<!-- chapter_plan -->`)).toBe(false)
+    expect(isFoldableChapterBody(longBody)).toBe(true)
+  })
+
+  it("resolves chapter numbers from chapterRef, tool params, then text", () => {
+    expect(resolveChapterNumberFromMessage({
+      role: "assistant",
+      content: longBody,
+      chapterRef: { chapterNumber: 9, path: "wiki/chapters/chapter-009.md", savedAt: 1 },
+      agentToolCalls: [{ name: "run_chapter_workflow", params: { chapterNumber: 12 } }],
+    })).toBe(9)
+
+    expect(resolveChapterNumberFromMessage({
+      role: "assistant",
+      content: "无关正文",
+      agentToolCalls: [{ name: "run_chapter_workflow", params: { chapterNumber: 12 } }],
+    })).toBe(12)
+
+    expect(resolveChapterNumberFromMessage({
+      role: "assistant",
+      content: "第15章正式开篇……",
+    })).toBe(15)
+  })
+
+  it("injects full body when chapter is not on disk", async () => {
+    fileExistsMock.mockResolvedValue(false)
+    findChapterFileByNumberMock.mockResolvedValue(null)
+
+    const content = await buildHistoryContentForModel({
+      role: "assistant",
+      content: longBody,
+      chapterRef: { chapterNumber: 12, path: `${projectPath}/wiki/chapters/chapter-012.md`, savedAt: 1 },
+    }, {
+      projectPath,
+      novelMode: true,
+      readChapterToolAvailable: true,
+    })
+
+    expect(content).toBe(longBody)
+  })
+
+  it("folds saved chapters into a pointer and keeps summary when available", async () => {
+    fileExistsMock.mockResolvedValue(true)
+    loadSnapshotMock.mockResolvedValue({ summary: "主角在车站发现旧照片。" })
+
+    const content = await buildHistoryContentForModel({
+      role: "assistant",
+      content: longBody,
+      chapterRef: { chapterNumber: 12, path: `${projectPath}/wiki/chapters/chapter-012.md`, savedAt: 1 },
+    }, {
+      projectPath,
+      novelMode: true,
+      readChapterToolAvailable: true,
+    })
+
+    expect(content).toContain("第12章正文已入库")
+    expect(content).toContain("wiki/chapters/chapter-012.md")
+    expect(content).toContain("提要:主角在车站发现旧照片。")
+    expect(content).toContain("read_chapter")
+    expect(content).not.toContain("正文内容。正文内容。")
+  })
+
+  it("falls back to full body after the saved chapter file is deleted", async () => {
+    fileExistsMock.mockResolvedValue(false)
+    findChapterFileByNumberMock.mockResolvedValue(null)
+
+    const content = await buildHistoryContentForModel({
+      role: "assistant",
+      content: longBody,
+      chapterRef: { chapterNumber: 12, path: `${projectPath}/wiki/chapters/chapter-012.md`, savedAt: 1 },
+    }, {
+      projectPath,
+      novelMode: true,
+    })
+
+    expect(content).toBe(longBody)
+  })
+
+  it("does not fold when read_chapter is unavailable or chapter number is missing", async () => {
+    fileExistsMock.mockResolvedValue(true)
+    const withoutTool = await buildHistoryContentForModel({
+      role: "assistant",
+      content: longBody,
+      chapterRef: { chapterNumber: 12, path: `${projectPath}/wiki/chapters/chapter-012.md`, savedAt: 1 },
+    }, {
+      projectPath,
+      novelMode: true,
+      readChapterToolAvailable: false,
+    })
+    expect(withoutTool).toBe(longBody)
+
+    const noNumberBody = "x".repeat(CHAPTER_BODY_FOLD_MIN_CHARS + 10)
+    const withoutNumber = await buildHistoryContentForModel({
+      role: "assistant",
+      content: noNumberBody,
+    }, {
+      projectPath,
+      novelMode: true,
+    })
+    expect(withoutNumber).toBe(noNumberBody)
+  })
+
+  it("omits summary line when snapshot is missing", () => {
+    const pointer = buildFoldedChapterPointer({
+      chapterNumber: 3,
+      relativePath: "wiki/chapters/chapter-003.md",
+      originalChars: 2000,
+    })
+    expect(pointer).toContain("第3章正文已入库")
+    expect(pointer).not.toContain("提要:")
+  })
+})

+ 188 - 0
src/lib/context-hub/chapter-body-injection.ts

@@ -0,0 +1,188 @@
+import { fileExists } from "@/commands/fs"
+import { loadSnapshot } from "@/lib/novel/chapter-ingest"
+import { extractChapterNumber, findChapterFileByNumber } from "@/lib/novel/chapter-utils"
+import { normalizePath } from "@/lib/path-utils"
+import type { AgentMessage } from "@/lib/agent/types"
+
+export const CHAPTER_BODY_FOLD_MIN_CHARS = 1500
+
+export interface ChapterRef {
+  chapterNumber: number
+  path: string
+  savedAt: number
+}
+
+export interface ChapterHistoryMessage {
+  id?: string
+  role: string
+  content: string
+  chapterRef?: ChapterRef
+  agentToolCalls?: Array<{
+    name: string
+    params?: Record<string, unknown>
+    result?: string
+    status?: string
+  }>
+  reasoning_content?: string
+}
+
+export interface BuildHistoryContentDeps {
+  projectPath: string
+  novelMode: boolean
+  /** When false, skip folding even if the chapter is on disk. */
+  readChapterToolAvailable?: boolean
+  pathMemo?: Map<number, string | null>
+}
+
+function paddedChapterFileName(chapterNumber: number): string {
+  return `chapter-${String(chapterNumber).padStart(3, "0")}.md`
+}
+
+export function isFoldableChapterBody(content: string): boolean {
+  if (!content || content.length < CHAPTER_BODY_FOLD_MIN_CHARS) return false
+  if (content.includes("<!-- chapter_plan -->")) return false
+  return true
+}
+
+function chapterNumberFromToolName(name: string): number | undefined {
+  const match = name.match(/(?:^|[^0-9])(\d{1,4})(?:[^0-9]|$)/)
+  if (!match?.[1]) return undefined
+  const value = Number.parseInt(match[1], 10)
+  return Number.isFinite(value) && value > 0 ? value : undefined
+}
+
+export function resolveChapterNumberFromMessage(
+  message: ChapterHistoryMessage,
+): number | undefined {
+  const fromRef = message.chapterRef?.chapterNumber
+  if (typeof fromRef === "number" && Number.isFinite(fromRef) && fromRef > 0) {
+    return Math.floor(fromRef)
+  }
+
+  for (const call of message.agentToolCalls ?? []) {
+    if (call.name === "run_chapter_workflow") {
+      const raw = call.params?.chapterNumber
+      const value = typeof raw === "number"
+        ? raw
+        : typeof raw === "string"
+          ? Number.parseInt(raw, 10)
+          : Number.NaN
+      if (Number.isFinite(value) && value > 0) return Math.floor(value)
+    }
+    if (call.name === "write_chapter") {
+      const name = typeof call.params?.name === "string" ? call.params.name : ""
+      const fromName = chapterNumberFromToolName(name) ?? extractChapterNumber(name) ?? undefined
+      if (fromName && fromName > 0) return fromName
+    }
+  }
+
+  const fromText = extractChapterNumber(message.content)
+  return fromText && fromText > 0 ? fromText : undefined
+}
+
+export async function resolveSavedChapterPath(
+  projectPath: string,
+  chapterNumber: number,
+  pathMemo?: Map<number, string | null>,
+): Promise<string | null> {
+  if (pathMemo?.has(chapterNumber)) {
+    return pathMemo.get(chapterNumber) ?? null
+  }
+
+  const pp = normalizePath(projectPath)
+  const fastPath = `${pp}/wiki/chapters/${paddedChapterFileName(chapterNumber)}`
+  let resolved: string | null = null
+  try {
+    if (await fileExists(fastPath)) {
+      resolved = fastPath
+    } else {
+      resolved = await findChapterFileByNumber(pp, chapterNumber)
+    }
+  } catch {
+    resolved = null
+  }
+
+  pathMemo?.set(chapterNumber, resolved)
+  return resolved
+}
+
+function toProjectRelativePath(projectPath: string, absolutePath: string): string {
+  const pp = normalizePath(projectPath).replace(/\/$/, "")
+  const normalized = normalizePath(absolutePath)
+  const prefix = `${pp}/`
+  return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized
+}
+
+export function buildFoldedChapterPointer(input: {
+  chapterNumber: number
+  relativePath: string
+  originalChars: number
+  summary?: string
+}): string {
+  const lines = [
+    `[第${input.chapterNumber}章正文已入库,未在此重复注入|原文约 ${input.originalChars} 字|${input.relativePath}]`,
+  ]
+  const summary = input.summary?.trim()
+  if (summary) lines.push(`提要:${summary}`)
+  lines.push("需要正文细节时用 read_chapter 读取该章节,以盘上版本为准。")
+  return lines.join("\n")
+}
+
+export async function buildHistoryContentForModel(
+  message: ChapterHistoryMessage,
+  deps: BuildHistoryContentDeps,
+): Promise<string> {
+  if (!deps.novelMode) return message.content
+  if (deps.readChapterToolAvailable === false) return message.content
+  if (message.role !== "assistant") return message.content
+  if (!isFoldableChapterBody(message.content)) return message.content
+
+  try {
+    const chapterNumber = resolveChapterNumberFromMessage(message)
+    if (!chapterNumber) return message.content
+
+    const savedPath = await resolveSavedChapterPath(
+      deps.projectPath,
+      chapterNumber,
+      deps.pathMemo,
+    )
+    if (!savedPath) return message.content
+
+    let summary = ""
+    try {
+      const snapshot = await loadSnapshot(deps.projectPath, chapterNumber)
+      summary = snapshot?.summary?.trim() ?? ""
+    } catch {
+      summary = ""
+    }
+
+    return buildFoldedChapterPointer({
+      chapterNumber,
+      relativePath: toProjectRelativePath(deps.projectPath, savedPath),
+      originalChars: message.content.length,
+      summary,
+    })
+  } catch {
+    return message.content
+  }
+}
+
+export async function buildAgentHistoryMessages(
+  messages: readonly ChapterHistoryMessage[],
+  deps: BuildHistoryContentDeps,
+): Promise<AgentMessage[]> {
+  const pathMemo = deps.pathMemo ?? new Map<number, string | null>()
+  const resolvedDeps = { ...deps, pathMemo }
+  const result: AgentMessage[] = []
+  for (const message of messages) {
+    const content = await buildHistoryContentForModel(message, resolvedDeps)
+    result.push({
+      role: message.role as AgentMessage["role"],
+      content,
+      ...(message.reasoning_content !== undefined
+        ? { reasoning_content: message.reasoning_content }
+        : {}),
+    })
+  }
+  return result
+}

+ 148 - 0
src/lib/context-usage.spec.ts

@@ -0,0 +1,148 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildContextUsageSnapshot,
+  calibrateContextUsageSnapshot,
+  composeLiveContextUsage,
+  formatContextTokenCount,
+  normalizeContextUsageSnapshot,
+} from "./context-usage"
+
+describe("context usage snapshot", () => {
+  it("builds local segments and marks estimated when provider usage is missing", () => {
+    const snapshot = buildContextUsageSnapshot({
+      windowTokens: 100_000,
+      softwareRules: "规则".repeat(20),
+      toolDefinitionsJson: '{"tools":[]}',
+      stableTokens: 1000,
+      summaryTokens: 200,
+      dynamicTokens: 400,
+      historyTexts: ["历史".repeat(50)],
+      currentInput: "继续写下一章",
+    })
+
+    expect(snapshot.estimated).toBe(true)
+    expect(snapshot.windowTokens).toBe(100_000)
+    expect(snapshot.totalTokens).toBeGreaterThan(0)
+    expect(snapshot.segments.reduce((sum, segment) => sum + segment.tokens, 0)).toBe(snapshot.totalTokens)
+    expect(snapshot.segments.find((segment) => segment.key === "stableCore")?.tokens).toBe(1000)
+  })
+
+  it("scales local segments to match provider prompt tokens", () => {
+    const local = buildContextUsageSnapshot({
+      windowTokens: 256_000,
+      softwareRules: "a".repeat(400),
+      stableTokens: 1000,
+      summaryTokens: 500,
+      dynamicTokens: 500,
+      historyTexts: ["b".repeat(400)],
+      currentInput: "c".repeat(400),
+    })
+    const calibrated = calibrateContextUsageSnapshot(local, { inputTokens: 8000 })
+
+    expect(calibrated.estimated).toBe(false)
+    expect(calibrated.totalTokens).toBe(8000)
+    expect(calibrated.segments.reduce((sum, segment) => sum + segment.tokens, 0)).toBe(8000)
+    expect(calibrated.segments.every((segment) => segment.tokens >= 0)).toBe(true)
+  })
+
+  it("keeps estimated=true when usage has no input tokens", () => {
+    const local = buildContextUsageSnapshot({
+      windowTokens: 10_000,
+      softwareRules: "hello",
+      historyTexts: ["world"],
+    })
+    const calibrated = calibrateContextUsageSnapshot(local, { outputTokens: 12 })
+    expect(calibrated.estimated).toBe(true)
+    expect(calibrated.totalTokens).toBe(local.totalTokens)
+  })
+
+  it("formats token counts compactly", () => {
+    expect(formatContextTokenCount(512)).toBe("512")
+    expect(formatContextTokenCount(11200)).toBe("11.2K")
+    expect(formatContextTokenCount(256_000)).toBe("256K")
+  })
+
+  it("normalizes persisted snapshots defensively", () => {
+    expect(normalizeContextUsageSnapshot(null)).toBeUndefined()
+    expect(normalizeContextUsageSnapshot({
+      windowTokens: 1000.8,
+      totalTokens: 12.2,
+      measuredAt: 1,
+      estimated: true,
+      segments: [{ key: "history", tokens: 12.9 }],
+    })).toEqual({
+      windowTokens: 1000,
+      totalTokens: 12,
+      measuredAt: 1,
+      estimated: true,
+      segments: [{ key: "history", tokens: 12 }],
+    })
+  })
+
+  it("composes a live overlay that keeps stable layers and refreshes draft on calibrated snapshots", () => {
+    const last = buildContextUsageSnapshot({
+      windowTokens: 100_000,
+      softwareRules: "规则".repeat(40),
+      stableTokens: 2000,
+      summaryTokens: 100,
+      dynamicTokens: 800,
+      historyTexts: ["旧历史"],
+      currentInput: "旧输入",
+      usage: { inputTokens: 5000 },
+    })
+    const live = composeLiveContextUsage(last, {
+      sessionSummaryText: "新的会话摘要内容".repeat(5),
+      historyTexts: ["新历史".repeat(20)],
+      currentInput: "正在输入下一章需求".repeat(8),
+    })
+
+    expect(live).not.toBeNull()
+    expect(live!.estimated).toBe(true)
+    expect(live!.segments.find((segment) => segment.key === "stableCore")?.tokens).toBe(
+      last.segments.find((segment) => segment.key === "stableCore")?.tokens,
+    )
+    expect(live!.segments.find((segment) => segment.key === "history")?.tokens).toBe(
+      last.segments.find((segment) => segment.key === "history")?.tokens,
+    )
+    expect(live!.segments.find((segment) => segment.key === "currentInput")?.tokens).toBeGreaterThan(
+      last.segments.find((segment) => segment.key === "currentInput")?.tokens ?? 0,
+    )
+    expect(live!.totalTokens).toBeGreaterThan(last.totalTokens)
+  })
+
+  it("can show a draft-only live estimate before the first measured request", () => {
+    const live = composeLiveContextUsage(null, {
+      windowTokens: 32_000,
+      currentInput: "第一次提问前的草稿".repeat(10),
+    })
+    expect(live).not.toBeNull()
+    expect(live!.segments.find((segment) => segment.key === "currentInput")?.tokens).toBeGreaterThan(0)
+    expect(composeLiveContextUsage(null, { windowTokens: 32_000, currentInput: "   " })).toBeNull()
+  })
+
+  it("adds pending tool reads on top of a calibrated snapshot without rewriting stable layers", () => {
+    const last = calibrateContextUsageSnapshot(
+      buildContextUsageSnapshot({
+        windowTokens: 100_000,
+        softwareRules: "规则".repeat(20),
+        stableTokens: 1000,
+        summaryTokens: 100,
+        dynamicTokens: 400,
+        historyTexts: ["历史"],
+        currentInput: "写下一章",
+      }),
+      { inputTokens: 10_000 },
+    )
+    const live = composeLiveContextUsage(last, {
+      currentInput: "写下一章",
+      pendingToolResultTexts: ["大纲正文".repeat(80)],
+    })
+
+    expect(live).not.toBeNull()
+    expect(live!.totalTokens).toBeGreaterThan(last.totalTokens)
+    expect(live!.segments.find((segment) => segment.key === "toolResults")?.tokens).toBeGreaterThan(0)
+    expect(live!.segments.find((segment) => segment.key === "stableCore")?.tokens).toBe(
+      last.segments.find((segment) => segment.key === "stableCore")?.tokens,
+    )
+  })
+})

+ 274 - 0
src/lib/context-usage.ts

@@ -0,0 +1,274 @@
+import { estimateContextTokens } from "@/lib/context-hub/token-estimator"
+import type { LlmUsage } from "@/lib/llm-usage"
+
+export type ContextUsageKey =
+  | "softwareRules"
+  | "toolDefinitions"
+  | "stableCore"
+  | "sessionSummary"
+  | "dynamicContext"
+  | "history"
+  | "toolResults"
+  | "currentInput"
+
+export interface ContextUsageSegment {
+  key: ContextUsageKey
+  tokens: number
+}
+
+export interface ContextUsageSnapshot {
+  windowTokens: number
+  totalTokens: number
+  segments: ContextUsageSegment[]
+  measuredAt: number
+  /** true = 无 provider usage,纯本地估算 */
+  estimated: boolean
+}
+
+export interface BuildContextUsageSnapshotInput {
+  windowTokens: number
+  softwareRules?: string
+  toolDefinitionsJson?: string
+  stableTokens?: number
+  summaryTokens?: number
+  dynamicTokens?: number
+  historyTexts?: string[]
+  currentInput?: string
+  usage?: LlmUsage
+  measuredAt?: number
+}
+
+export const CONTEXT_USAGE_SEGMENT_ORDER: ContextUsageKey[] = [
+  "softwareRules",
+  "toolDefinitions",
+  "stableCore",
+  "sessionSummary",
+  "dynamicContext",
+  "history",
+  "toolResults",
+  "currentInput",
+]
+
+export const CONTEXT_USAGE_WARN_RATIO = 0.75
+export const CONTEXT_USAGE_FULL_RATIO = 0.9
+
+function nonNegativeInt(value: number | undefined): number {
+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0
+  return Math.floor(value)
+}
+
+function estimateText(value: string | undefined): number {
+  return value?.trim() ? estimateContextTokens(value) : 0
+}
+
+export function buildContextUsageSnapshot(
+  input: BuildContextUsageSnapshotInput,
+): ContextUsageSnapshot {
+  const windowTokens = Math.max(1, nonNegativeInt(input.windowTokens) || 1)
+  const localSegments: ContextUsageSegment[] = [
+    { key: "softwareRules", tokens: estimateText(input.softwareRules) },
+    { key: "toolDefinitions", tokens: estimateText(input.toolDefinitionsJson) },
+    { key: "stableCore", tokens: nonNegativeInt(input.stableTokens) },
+    { key: "sessionSummary", tokens: nonNegativeInt(input.summaryTokens) },
+    { key: "dynamicContext", tokens: nonNegativeInt(input.dynamicTokens) },
+    {
+      key: "history",
+      tokens: (input.historyTexts ?? []).reduce((sum, text) => sum + estimateText(text), 0),
+    },
+    { key: "toolResults", tokens: 0 },
+    { key: "currentInput", tokens: estimateText(input.currentInput) },
+  ]
+  const localTotal = localSegments.reduce((sum, segment) => sum + segment.tokens, 0)
+  const providerPromptTokens = nonNegativeInt(input.usage?.inputTokens)
+  const estimated = providerPromptTokens <= 0
+  const totalTokens = estimated ? localTotal : providerPromptTokens
+
+  let segments = localSegments
+  if (!estimated && localTotal > 0 && totalTokens !== localTotal) {
+    const scale = totalTokens / localTotal
+    let assigned = 0
+    segments = localSegments.map((segment, index) => {
+      if (index === localSegments.length - 1) {
+        return { ...segment, tokens: Math.max(0, totalTokens - assigned) }
+      }
+      const scaled = Math.max(0, Math.round(segment.tokens * scale))
+      assigned += scaled
+      return { ...segment, tokens: scaled }
+    })
+  } else if (!estimated && localTotal === 0 && totalTokens > 0) {
+    segments = localSegments.map((segment) =>
+      segment.key === "history"
+        ? { ...segment, tokens: totalTokens }
+        : { ...segment, tokens: 0 },
+    )
+  }
+
+  return {
+    windowTokens,
+    totalTokens,
+    segments,
+    measuredAt: typeof input.measuredAt === "number" && Number.isFinite(input.measuredAt)
+      ? input.measuredAt
+      : Date.now(),
+    estimated,
+  }
+}
+
+export function calibrateContextUsageSnapshot(
+  snapshot: ContextUsageSnapshot,
+  usage?: LlmUsage,
+): ContextUsageSnapshot {
+  const providerPromptTokens = nonNegativeInt(usage?.inputTokens)
+  if (providerPromptTokens <= 0) {
+    return {
+      ...snapshot,
+      segments: snapshot.segments.map((segment) => ({ ...segment })),
+      estimated: true,
+      measuredAt: Date.now(),
+    }
+  }
+  const localTotal = snapshot.segments.reduce((sum, segment) => sum + segment.tokens, 0)
+  if (localTotal <= 0) {
+    return {
+      windowTokens: snapshot.windowTokens,
+      totalTokens: providerPromptTokens,
+      segments: CONTEXT_USAGE_SEGMENT_ORDER.map((key) => ({
+        key,
+        tokens: key === "history" ? providerPromptTokens : 0,
+      })),
+      measuredAt: Date.now(),
+      estimated: false,
+    }
+  }
+  const scale = providerPromptTokens / localTotal
+  let assigned = 0
+  const segments = snapshot.segments.map((segment, index) => {
+    if (index === snapshot.segments.length - 1) {
+      return { ...segment, tokens: Math.max(0, providerPromptTokens - assigned) }
+    }
+    const scaled = Math.max(0, Math.round(segment.tokens * scale))
+    assigned += scaled
+    return { ...segment, tokens: scaled }
+  })
+  return {
+    windowTokens: snapshot.windowTokens,
+    totalTokens: providerPromptTokens,
+    segments,
+    measuredAt: Date.now(),
+    estimated: false,
+  }
+}
+
+/**
+ * Overlay live draft / pending tool reads on top of the last measured request.
+ *
+ * - Calibrated snapshots keep provider `totalTokens` as the base and only add
+ *   input-draft deltas plus tool results that finished after `measuredAt`.
+ * - Estimated snapshots recompute mutable layers locally.
+ */
+export function composeLiveContextUsage(
+  lastUsage: ContextUsageSnapshot | null | undefined,
+  live: {
+    windowTokens?: number
+    sessionSummaryText?: string
+    historyTexts?: string[]
+    currentInput?: string
+    pendingToolResultTexts?: string[]
+  },
+): ContextUsageSnapshot | null {
+  const historyTokens = (live.historyTexts ?? []).reduce(
+    (sum, text) => sum + estimateText(text),
+    0,
+  )
+  const currentInputTokens = estimateText(live.currentInput)
+  const pendingToolTokens = (live.pendingToolResultTexts ?? []).reduce(
+    (sum, text) => sum + estimateText(text),
+    0,
+  )
+  const summaryTokens = live.sessionSummaryText !== undefined
+    ? estimateText(live.sessionSummaryText)
+    : nonNegativeInt(lastUsage?.segments.find((segment) => segment.key === "sessionSummary")?.tokens)
+  const windowTokens = Math.max(
+    1,
+    nonNegativeInt(live.windowTokens) || nonNegativeInt(lastUsage?.windowTokens) || 1,
+  )
+  const lastByKey = new Map(
+    (lastUsage?.segments ?? []).map((segment) => [segment.key, nonNegativeInt(segment.tokens)]),
+  )
+  const lastInputTokens = lastByKey.get("currentInput") ?? 0
+  const calibrated = Boolean(lastUsage && !lastUsage.estimated)
+
+  const segments: ContextUsageSegment[] = CONTEXT_USAGE_SEGMENT_ORDER.map((key) => {
+    if (key === "currentInput") return { key, tokens: currentInputTokens }
+    if (key === "toolResults") return { key, tokens: pendingToolTokens }
+    if (!calibrated && key === "history") return { key, tokens: historyTokens }
+    if (!calibrated && key === "sessionSummary") return { key, tokens: summaryTokens }
+    return { key, tokens: lastByKey.get(key) ?? 0 }
+  })
+
+  let totalTokens = segments.reduce((sum, segment) => sum + segment.tokens, 0)
+  if (calibrated && lastUsage) {
+    totalTokens = Math.max(
+      0,
+      lastUsage.totalTokens + (currentInputTokens - lastInputTokens) + pendingToolTokens,
+    )
+  }
+  if (!lastUsage && totalTokens <= 0) return null
+  return {
+    windowTokens,
+    totalTokens,
+    segments,
+    measuredAt: lastUsage?.measuredAt ?? Date.now(),
+    estimated: !calibrated || pendingToolTokens > 0 || currentInputTokens !== lastInputTokens,
+  }
+}
+
+export function contextUsageRatio(snapshot: ContextUsageSnapshot): number {
+  if (snapshot.windowTokens <= 0) return 0
+  return Math.min(1, snapshot.totalTokens / snapshot.windowTokens)
+}
+
+export function formatContextTokenCount(tokens: number): string {
+  if (!Number.isFinite(tokens) || tokens < 0) return "0"
+  if (tokens < 1000) return String(Math.round(tokens))
+  const thousands = tokens / 1000
+  if (thousands < 100) {
+    const rounded = Math.round(thousands * 10) / 10
+    return `${rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(1)}K`
+  }
+  return `${Math.round(thousands)}K`
+}
+
+export function isContextUsageSnapshot(value: unknown): value is ContextUsageSnapshot {
+  if (!value || typeof value !== "object") return false
+  const candidate = value as Partial<ContextUsageSnapshot>
+  if (
+    typeof candidate.windowTokens !== "number"
+    || typeof candidate.totalTokens !== "number"
+    || typeof candidate.measuredAt !== "number"
+    || typeof candidate.estimated !== "boolean"
+    || !Array.isArray(candidate.segments)
+  ) {
+    return false
+  }
+  return candidate.segments.every((segment) => (
+    segment
+    && typeof segment === "object"
+    && typeof (segment as ContextUsageSegment).key === "string"
+    && typeof (segment as ContextUsageSegment).tokens === "number"
+  ))
+}
+
+export function normalizeContextUsageSnapshot(value: unknown): ContextUsageSnapshot | undefined {
+  if (!isContextUsageSnapshot(value)) return undefined
+  return {
+    windowTokens: Math.max(1, Math.floor(value.windowTokens)),
+    totalTokens: Math.max(0, Math.floor(value.totalTokens)),
+    segments: value.segments.map((segment) => ({
+      key: segment.key,
+      tokens: Math.max(0, Math.floor(segment.tokens)),
+    })),
+    measuredAt: value.measuredAt,
+    estimated: value.estimated,
+  }
+}

+ 2 - 0
src/lib/persist.ts

@@ -7,6 +7,7 @@ import {
   isLegacySessionContextSummary,
   normalizeSessionContextSummary,
 } from "@/lib/context-hub/session-summary"
+import { normalizeContextUsageSnapshot } from "@/lib/context-usage"
 import { getContextHub } from "@/lib/context-hub/context-hub"
 
 const MAX_RETRIES = 3
@@ -131,6 +132,7 @@ function normalizeConversation(conv: Conversation): Conversation {
         ? conv.selectedDeAiSkillId
         : undefined,
     contextSummary: normalizeSessionContextSummary(conv.contextSummary),
+    lastContextUsage: normalizeContextUsageSnapshot(conv.lastContextUsage),
   }
 }
 

+ 23 - 0
src/stores/chat-store.ts

@@ -4,6 +4,8 @@ import type { AgentRunRecord, AgentStageTrace } from "@/lib/agent/types"
 import type { ReferenceToken } from "@/lib/reference/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
 import type { ContextHubSnapshotRef, SessionContextSummary } from "@/lib/context-hub/types"
+import type { ChapterRef } from "@/lib/context-hub/chapter-body-injection"
+import type { ContextUsageSnapshot } from "@/lib/context-usage"
 import i18n from "@/i18n"
 import {
   canStartConversationRun as canStartRun,
@@ -23,6 +25,7 @@ export interface Conversation {
   selectedDeAiSkillId?: string | null
   inputDraft?: string
   contextSummary?: SessionContextSummary
+  lastContextUsage?: ContextUsageSnapshot
 }
 
 export interface MessageReference {
@@ -45,6 +48,8 @@ export interface DisplayMessage {
   contextTrace?: ContextTrace
   contextHubSnapshot?: ContextHubSnapshotRef
   reasoning_content?: string
+  /** Chapter number / path clue written when the user saves to the chapter library. */
+  chapterRef?: ChapterRef
 }
 
 interface ChatState {
@@ -68,6 +73,8 @@ interface ChatState {
   setConversationDeAiSkillId: (id: string, skillId: string | null | undefined) => void
   setConversationInputDraft: (id: string, draft: string) => void
   setConversationContextSummary: (id: string, contextSummary: SessionContextSummary | undefined) => void
+  setConversationContextUsage: (id: string, lastContextUsage: ContextUsageSnapshot | undefined) => void
+  setMessageChapterRef: (messageId: string, chapterRef: ChapterRef | undefined) => void
 
   // Message management
   addMessage: (role: DisplayMessage["role"], content: string) => void
@@ -215,6 +222,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
       ),
     })),
 
+  setConversationContextUsage: (id, lastContextUsage) =>
+    set((state) => ({
+      conversations: state.conversations.map((conversation) =>
+        conversation.id === id
+          ? { ...conversation, lastContextUsage, updatedAt: Date.now() }
+          : conversation
+      ),
+    })),
+
+  setMessageChapterRef: (messageId, chapterRef) =>
+    set((state) => ({
+      messages: state.messages.map((message) =>
+        message.id === messageId ? { ...message, chapterRef } : message
+      ),
+    })),
+
   addMessage: (role, content) =>
     set((state) => {
       const { activeConversationId, conversations } = state

+ 17 - 0
src/stores/outline-chat-store.ts

@@ -8,6 +8,10 @@ import {
   isLegacySessionContextSummary,
   normalizeSessionContextSummary,
 } from "@/lib/context-hub/session-summary"
+import {
+  normalizeContextUsageSnapshot,
+  type ContextUsageSnapshot,
+} from "@/lib/context-usage"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
@@ -107,6 +111,7 @@ export interface OutlineChatConversation {
   messages: OutlineChatMessage[]
   modelId?: string
   contextSummary?: SessionContextSummary
+  lastContextUsage?: ContextUsageSnapshot
 }
 
 interface OutlineChatState {
@@ -126,6 +131,7 @@ interface OutlineChatState {
   deleteConversation: (id: string) => void
   setConversationModel: (id: string, modelId: string) => void
   setConversationContextSummary: (id: string, contextSummary: SessionContextSummary) => void
+  setConversationContextUsage: (id: string, lastContextUsage: ContextUsageSnapshot | undefined) => void
   setStreamingContent: (conversationId: string, content: string) => void
   clearStreamingContent: (conversationId: string) => void
   getStreamingContent: (conversationId: string) => string
@@ -316,6 +322,16 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
     scheduleSave()
   },
 
+  setConversationContextUsage: (id, lastContextUsage) => {
+    const now = Date.now()
+    set((s) => ({
+      conversations: s.conversations.map((c) =>
+        c.id === id ? { ...c, lastContextUsage, updatedAt: now } : c
+      ),
+    }))
+    scheduleSave()
+  },
+
   setStreamingContent: (conversationId, content) => set((state) => ({
     streamingContents: { ...state.streamingContents, [conversationId]: content },
   })),
@@ -394,6 +410,7 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
       const conversations = (data.conversations ?? []).map((conversation) => ({
         ...conversation,
         contextSummary: normalizeSessionContextSummary(conversation.contextSummary),
+        lastContextUsage: normalizeContextUsageSnapshot(conversation.lastContextUsage),
         updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
         messages: conversation.messages.map((message) => ({
           ...message,