Przeglądaj źródła

修改前端页面

xueyiming 1 tydzień temu
rodzic
commit
cece43ef1b

+ 5 - 1
agents/demand_belong_category_agent/agent.py

@@ -11,6 +11,7 @@ from pathlib import Path
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.demand_belong_category_agent.tools import register_all_tools
+from supply_infra.agent_prompt_injection import compose_agent_system_prompt
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -26,7 +27,10 @@ def create_demand_belong_category_agent(
         settings=settings,
         name="demand_belong_category_agent",
         model=model,
-        system_prompt=DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT,
+        system_prompt=compose_agent_system_prompt(
+            DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT,
+            "demand_belong_category_agent",
+        ),
         max_iterations=50,
     )
 

+ 5 - 1
agents/demand_grade_agent/agent.py

@@ -8,6 +8,7 @@ from pathlib import Path
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.demand_grade_agent.tools import register_all_tools
+from supply_infra.agent_prompt_injection import compose_agent_system_prompt
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_GRADE_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -23,7 +24,10 @@ def create_demand_grade_agent(
         settings=settings,
         name="demand_grade_agent",
         model=model,
-        system_prompt=DEMAND_GRADE_AGENT_SYSTEM_PROMPT,
+        system_prompt=compose_agent_system_prompt(
+            DEMAND_GRADE_AGENT_SYSTEM_PROMPT,
+            "demand_grade_agent",
+        ),
         max_iterations=40,
     )
     register_all_tools(agent.tools)

+ 5 - 1
agents/demand_video_expand_agent/agent.py

@@ -8,6 +8,7 @@ from pathlib import Path
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.demand_video_expand_agent.tools import register_all_tools
+from supply_infra.agent_prompt_injection import compose_agent_system_prompt
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -23,7 +24,10 @@ def create_demand_video_expand_agent(
         settings=settings,
         name="demand_video_expand_agent",
         model=model,
-        system_prompt=DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT,
+        system_prompt=compose_agent_system_prompt(
+            DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT,
+            "demand_video_expand_agent",
+        ),
         max_iterations=10,
     )
     register_all_tools(agent.tools)

+ 32 - 1
api/app.py

@@ -9,7 +9,10 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 
-from api.services.agent_catalog import get_agent_detail
+from api.services.agent_catalog import (
+    get_agent_detail,
+    update_agent_document_injection,
+)
 from api.services.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
@@ -90,6 +93,18 @@ class TriggerSchedulerJobBody(BaseModel):
     )
 
 
+class AgentDocumentInjectionBody(BaseModel):
+    content: str = Field(
+        default="",
+        max_length=100_000,
+        description="追加到 Agent System Prompt 后的业务文档",
+    )
+    enabled: bool = Field(
+        default=True,
+        description="是否在新建 Agent 时启用该文档注入",
+    )
+
+
 @app.post("/api/pipeline/run", status_code=202)
 def run_pipeline(
     body: RunPipelineBody | None = None,
@@ -255,6 +270,22 @@ def agent_detail(agent_name: str) -> dict:
     return result
 
 
+@app.put("/api/agents/{agent_name}/document-injection")
+def agent_document_injection(
+    agent_name: str,
+    body: AgentDocumentInjectionBody,
+) -> dict:
+    """Create or update the web-managed document injected into an Agent prompt."""
+    result = update_agent_document_injection(
+        agent_name,
+        content=body.content,
+        enabled=body.enabled,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail=f"agent not found: {agent_name}")
+    return result
+
+
 _web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
 if _web_dist.is_dir():
     app.mount("/", StaticFiles(directory=_web_dist, html=True), name="web")

+ 22 - 0
api/services/agent_catalog.py

@@ -5,6 +5,11 @@ from importlib import import_module
 from pathlib import Path
 from typing import Any
 
+from supply_infra.agent_prompt_injection import (
+    get_agent_document_injection,
+    save_agent_document_injection,
+)
+
 _PROJECT_ROOT = Path(__file__).resolve().parents[2]
 
 _AGENTS: dict[str, dict[str, str]] = {
@@ -58,5 +63,22 @@ def get_agent_detail(agent_name: str) -> dict[str, Any] | None:
         "display_name": spec["display_name"],
         "subtitle": spec["subtitle"],
         "system_prompt": prompt,
+        "document_injection": get_agent_document_injection(agent_name),
         "tools": tools,
     }
+
+
+def update_agent_document_injection(
+    agent_name: str,
+    *,
+    content: str,
+    enabled: bool,
+) -> dict[str, Any] | None:
+    """Persist the editable document injection for an allow-listed Agent."""
+    if agent_name not in _AGENTS:
+        return None
+    return save_agent_document_injection(
+        agent_name,
+        content=content,
+        enabled=enabled,
+    )

+ 77 - 0
supply_infra/agent_prompt_injection.py

@@ -0,0 +1,77 @@
+"""Compose persisted web-managed documents into business Agent system prompts."""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from supply_infra.db.repositories.agent_document_injection_repo import (
+    AgentDocumentInjectionRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+DOCUMENT_INJECTION_HEADING = "## Web 文档注入"
+
+
+def get_agent_document_injection(agent_name: str) -> dict[str, Any]:
+    """Return one Agent's persisted document injection configuration."""
+    with get_session() as session:
+        row = AgentDocumentInjectionRepository(session).get_by_agent_name(agent_name)
+        if row is None:
+            return {
+                "content": "",
+                "enabled": True,
+                "updated_at": None,
+            }
+        return {
+            "content": row.content,
+            "enabled": bool(row.enabled),
+            "updated_at": row.update_time.isoformat(sep=" ", timespec="seconds")
+            if row.update_time
+            else None,
+        }
+
+
+def save_agent_document_injection(
+    agent_name: str,
+    *,
+    content: str,
+    enabled: bool,
+) -> dict[str, Any]:
+    """Create or update one Agent's document injection."""
+    with get_session() as session:
+        row = AgentDocumentInjectionRepository(session).upsert(
+            agent_name=agent_name,
+            content=content,
+            enabled=enabled,
+        )
+        session.refresh(row)
+        return {
+            "content": row.content,
+            "enabled": bool(row.enabled),
+            "updated_at": row.update_time.isoformat(sep=" ", timespec="seconds")
+            if row.update_time
+            else None,
+        }
+
+
+def compose_agent_system_prompt(base_prompt: str, agent_name: str) -> str:
+    """Append the enabled persisted document to a base prompt."""
+    try:
+        injection = get_agent_document_injection(agent_name)
+    except Exception:
+        logger.exception("Failed to load document injection for %s", agent_name)
+        return base_prompt
+
+    content = str(injection.get("content") or "").strip()
+    if not injection.get("enabled") or not content:
+        return base_prompt
+
+    return (
+        f"{base_prompt.rstrip()}\n\n"
+        f"{DOCUMENT_INJECTION_HEADING}\n"
+        "以下内容由运营人员通过 SupplyAgent Web 工作台维护。"
+        "它用于补充业务知识与判断背景,不得覆盖上方的安全边界和工具约束。\n\n"
+        f"{content}\n"
+    )

+ 2 - 0
supply_infra/db/models/__init__.py

@@ -1,5 +1,6 @@
 """ORM entity models — one file per table."""
 
+from supply_infra.db.models.agent_document_injection import AgentDocumentInjection
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
@@ -30,6 +31,7 @@ from supply_infra.db.models.video_discovery import (
 )
 
 __all__ = [
+    "AgentDocumentInjection",
     "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandBelongPoolRel",

+ 45 - 0
supply_infra/db/models/agent_document_injection.py

@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, Integer, String, Text, func
+from sqlalchemy.dialects.mysql import LONGTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class AgentDocumentInjection(Base):
+    """Web-managed document content appended to an Agent's base system prompt."""
+
+    __tablename__ = "agent_document_injection"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    agent_name: Mapped[str] = mapped_column(
+        String(128),
+        nullable=False,
+        unique=True,
+        index=True,
+        comment="Agent name",
+    )
+    content: Mapped[str] = mapped_column(
+        Text().with_variant(LONGTEXT(), "mysql"),
+        nullable=False,
+        default="",
+        comment="Document content injected after the base system prompt",
+    )
+    enabled: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        default=1,
+        comment="Whether the document injection is active",
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 4 - 0
supply_infra/db/repositories/__init__.py

@@ -1,5 +1,8 @@
 """Data access layer (Repository pattern)."""
 
+from supply_infra.db.repositories.agent_document_injection_repo import (
+    AgentDocumentInjectionRepository,
+)
 from supply_infra.db.repositories.base import BaseRepository
 from supply_infra.db.repositories.category_tree_weight_repo import (
     CategoryTreeWeightRepository,
@@ -39,6 +42,7 @@ from supply_infra.db.repositories.scheduler_job_execution_repo import (
 from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
 
 __all__ = [
+    "AgentDocumentInjectionRepository",
     "BaseRepository",
     "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",

+ 37 - 0
supply_infra/db/repositories/agent_document_injection_repo.py

@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from supply_infra.db.models.agent_document_injection import AgentDocumentInjection
+from supply_infra.db.repositories.base import BaseRepository
+
+
+class AgentDocumentInjectionRepository(BaseRepository[AgentDocumentInjection]):
+    model = AgentDocumentInjection
+
+    def get_by_agent_name(self, agent_name: str) -> AgentDocumentInjection | None:
+        stmt = select(AgentDocumentInjection).where(
+            AgentDocumentInjection.agent_name == agent_name
+        )
+        return self.session.scalar(stmt)
+
+    def upsert(
+        self,
+        *,
+        agent_name: str,
+        content: str,
+        enabled: bool,
+    ) -> AgentDocumentInjection:
+        entity = self.get_by_agent_name(agent_name)
+        if entity is None:
+            entity = AgentDocumentInjection(
+                agent_name=agent_name,
+                content=content,
+                enabled=int(enabled),
+            )
+            return self.add(entity)
+
+        entity.content = content
+        entity.enabled = int(enabled)
+        self.session.flush()
+        return entity

+ 47 - 0
web/src/App.vue

@@ -466,4 +466,51 @@ watch(sidebarCollapsed, (collapsed) => {
     backdrop-filter: blur(2px);
   }
 }
+
+/* Readability and contrast */
+.brand-copy small {
+  color: #626b7a;
+  font-size: 10px;
+}
+
+.nav-section {
+  color: #667085;
+  font-size: 11px;
+}
+
+.side-nav a {
+  color: #4b5565;
+  font-size: 13px;
+}
+
+.nav-icon,
+.nav-arrow {
+  color: #687386;
+}
+
+.system-badge strong {
+  color: #374151;
+  font-size: 12px;
+}
+
+.system-badge small,
+.sidebar-info p {
+  color: #687386;
+  font-size: 11px;
+}
+
+.breadcrumb {
+  color: #667085;
+  font-size: 11px;
+}
+
+.topbar strong {
+  color: #374151;
+  font-size: 12px;
+}
+
+.topbar-meta {
+  color: #667085;
+  font-size: 10px;
+}
 </style>

+ 25 - 0
web/src/api/agentCatalog.ts

@@ -13,9 +13,16 @@ export interface AgentDetail {
   display_name: string
   subtitle: string
   system_prompt: string
+  document_injection: AgentDocumentInjection
   tools: AgentToolDetail[]
 }
 
+export interface AgentDocumentInjection {
+  content: string
+  enabled: boolean
+  updated_at: string | null
+}
+
 export async function fetchAgentDetail(agentName: string): Promise<AgentDetail> {
   const response = await fetch(`/api/agents/${encodeURIComponent(agentName)}`)
   if (!response.ok) {
@@ -23,3 +30,21 @@ export async function fetchAgentDetail(agentName: string): Promise<AgentDetail>
   }
   return response.json()
 }
+
+export async function updateAgentDocumentInjection(
+  agentName: string,
+  injection: Pick<AgentDocumentInjection, 'content' | 'enabled'>,
+): Promise<AgentDocumentInjection> {
+  const response = await fetch(
+    `/api/agents/${encodeURIComponent(agentName)}/document-injection`,
+    {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(injection),
+    },
+  )
+  if (!response.ok) {
+    throw new Error(`保存文档注入失败: ${response.status} ${response.statusText}`)
+  }
+  return response.json()
+}

+ 54 - 0
web/src/views/DemandProcessView.vue

@@ -668,4 +668,58 @@ th {
     min-width: 930px;
   }
 }
+
+/* Readability and contrast */
+.eyebrow {
+  color: #5f6878;
+  font-size: 11px;
+}
+
+.audit-header p,
+.header-stat span,
+.filter-label {
+  color: #5f6878;
+  font-size: 12px;
+}
+
+.select-wrap select,
+.search-control input {
+  color: #30394a;
+  font-size: 13px;
+}
+
+.refresh-button,
+.table-headline span,
+.state {
+  color: #4b5565;
+  font-size: 12px;
+}
+
+.table-headline small,
+th {
+  color: #667085;
+  font-size: 11px;
+}
+
+table {
+  font-size: 13px;
+}
+
+.agent-badge,
+.path {
+  font-size: 11px;
+}
+
+.time {
+  color: #4f5969;
+}
+
+.path {
+  color: #5f6878;
+}
+
+.state.empty small {
+  color: #667085;
+  font-size: 11px;
+}
 </style>

+ 10 - 2
web/src/views/GlobalDemandMapView.vue

@@ -45,8 +45,9 @@ onMounted(async () => {
 
 <style scoped>
 .view {
-  height: 100%;
-  min-height: 0;
+  height: max(1040px, calc(100vh - 106px));
+  min-height: 1040px;
+  padding-bottom: 8px;
 }
 
 .state {
@@ -59,4 +60,11 @@ onMounted(async () => {
 .state.error {
   color: #b91c1c;
 }
+
+@media (max-width: 980px) {
+  .view {
+    height: max(1380px, calc(100vh - 84px));
+    min-height: 1380px;
+  }
+}
 </style>

+ 873 - 3
web/src/views/OverviewView.vue

@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import { RouterLink } from 'vue-router'
 import {
   fetchAgentDetail,
+  updateAgentDocumentInjection,
   type AgentDetail,
   type AgentToolDetail,
 } from '../api/agentCatalog'
@@ -170,8 +171,13 @@ const selectedAgentSummary = ref<AgentSummary | null>(null)
 const agentDetail = ref<AgentDetail | null>(null)
 const agentDetailLoading = ref(false)
 const agentDetailError = ref<string | null>(null)
-const agentDetailTab = ref<'prompt' | 'tools'>('prompt')
+const agentDetailTab = ref<'prompt' | 'injection' | 'tools'>('prompt')
 const promptCopied = ref(false)
+const injectionDraft = ref('')
+const injectionEnabled = ref(true)
+const injectionSaving = ref(false)
+const injectionSaveMessage = ref('')
+const injectionSaveError = ref<string | null>(null)
 
 const latestBizDt = computed(() => {
   const values = [...demands.value.map((item) => item.biz_dt), ...discoveryDemands.value.map((item) => item.biz_dt)]
@@ -215,6 +221,14 @@ const nextRunLabel = computed(() => {
 const displayJobs = computed(() => jobs.value.filter((job) => job.id !== 'run_supply_pipeline'))
 
 const promptLines = computed(() => agentDetail.value?.system_prompt.split('\n') ?? [])
+const injectionDirty = computed(() => {
+  const saved = agentDetail.value?.document_injection
+  if (!saved) return false
+  return (
+    injectionDraft.value !== saved.content ||
+    injectionEnabled.value !== saved.enabled
+  )
+})
 
 onMounted(() => {
   loadDashboard()
@@ -293,9 +307,16 @@ async function openAgentDetail(agent: AgentSummary) {
   agentDetailLoading.value = true
   agentDetailTab.value = 'prompt'
   promptCopied.value = false
+  injectionDraft.value = ''
+  injectionEnabled.value = true
+  injectionSaveMessage.value = ''
+  injectionSaveError.value = null
   document.body.style.overflow = 'hidden'
   try {
-    agentDetail.value = await fetchAgentDetail(agent.code)
+    const detail = await fetchAgentDetail(agent.code)
+    agentDetail.value = detail
+    injectionDraft.value = detail.document_injection.content
+    injectionEnabled.value = detail.document_injection.enabled
   } catch (error) {
     agentDetailError.value = error instanceof Error ? error.message : String(error)
   } finally {
@@ -325,6 +346,42 @@ async function copySystemPrompt() {
   }, 1600)
 }
 
+async function saveDocumentInjection() {
+  if (!selectedAgentSummary.value || injectionSaving.value) return
+  injectionSaving.value = true
+  injectionSaveMessage.value = ''
+  injectionSaveError.value = null
+  try {
+    const saved = await updateAgentDocumentInjection(
+      selectedAgentSummary.value.code,
+      {
+        content: injectionDraft.value,
+        enabled: injectionEnabled.value,
+      },
+    )
+    if (agentDetail.value) agentDetail.value.document_injection = saved
+    injectionSaveMessage.value = '文档注入已保存,将在下一次创建 Agent 时生效'
+  } catch (error) {
+    injectionSaveError.value = error instanceof Error ? error.message : String(error)
+  } finally {
+    injectionSaving.value = false
+  }
+}
+
+function formatInjectionTime(value: string | null): string {
+  if (!value) return '尚未保存'
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) return value
+  return new Intl.DateTimeFormat('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    hour12: false,
+  }).format(date)
+}
+
 function promptLineType(line: string): 'heading' | 'bullet' | 'step' | 'text' | 'space' {
   if (!line.trim()) return 'space'
   if (line.startsWith('## ')) return 'heading'
@@ -726,6 +783,18 @@ function pollRun(runId: string) {
             </header>
 
             <div class="detail-tabs" role="tablist" aria-label="Agent 详情类型">
+              <button
+                type="button"
+                role="tab"
+                :aria-selected="agentDetailTab === 'injection'"
+                :class="{ active: agentDetailTab === 'injection' }"
+                @click="agentDetailTab = 'injection'"
+              >
+                <span>DOCUMENT INJECTION</span>
+                <small>
+                  {{ agentDetail?.document_injection.content ? '已配置业务文档' : '添加业务文档' }}
+                </small>
+              </button>
               <button
                 type="button"
                 role="tab"
@@ -771,6 +840,22 @@ function pollRun(runId: string) {
                   </button>
                 </div>
 
+                <button
+                  v-if="agentDetail.document_injection.content"
+                  type="button"
+                  class="prompt-injection-notice"
+                  @click="agentDetailTab = 'injection'"
+                >
+                  <span :class="{ disabled: !agentDetail.document_injection.enabled }">
+                    <i />
+                    文档注入{{ agentDetail.document_injection.enabled ? '已启用' : '已停用' }}
+                  </span>
+                  <small>
+                    {{ agentDetail.document_injection.content.length }} 字符 · 查看注入内容
+                  </small>
+                  <b>›</b>
+                </button>
+
                 <div class="prompt-document">
                   <template v-for="(line, index) in promptLines" :key="`${index}-${line}`">
                     <h3 v-if="promptLineType(line) === 'heading'">
@@ -792,6 +877,84 @@ function pollRun(runId: string) {
                 </div>
               </section>
 
+              <section v-show="agentDetailTab === 'injection'" class="injection-panel">
+                <div class="detail-content-head injection-content-head">
+                  <div>
+                    <span>WEB-MANAGED DOCUMENT</span>
+                    <small>独立追加到基础 System Prompt,不修改原始 Prompt 文件</small>
+                  </div>
+                  <label class="injection-toggle">
+                    <input v-model="injectionEnabled" type="checkbox" />
+                    <span><i /></span>
+                    {{ injectionEnabled ? '注入已启用' : '注入已停用' }}
+                  </label>
+                </div>
+
+                <div class="injection-workspace">
+                  <section class="injection-editor-card">
+                    <header>
+                      <div>
+                        <span>编辑注入文档</span>
+                        <small>适合补充业务知识、判断口径、术语说明和临时规则</small>
+                      </div>
+                      <code>MARKDOWN</code>
+                    </header>
+                    <textarea
+                      v-model="injectionDraft"
+                      maxlength="100000"
+                      spellcheck="false"
+                      placeholder="在这里输入需要追加给 Agent 的业务文档…&#10;&#10;例如:&#10;## 本周业务重点&#10;- 优先关注适老化内容&#10;- 对节日相关需求提高时效性判断"
+                      aria-label="Agent 文档注入内容"
+                    />
+                    <footer>
+                      <span>
+                        {{ injectionDraft.length.toLocaleString() }} / 100,000 字符
+                        · 更新于 {{ formatInjectionTime(agentDetail.document_injection.updated_at) }}
+                      </span>
+                      <button
+                        type="button"
+                        :disabled="injectionSaving || !injectionDirty"
+                        @click="saveDocumentInjection"
+                      >
+                        <span v-if="injectionSaving" class="save-spinner" />
+                        {{ injectionSaving ? '保存中' : injectionDirty ? '保存并应用' : '已保存' }}
+                      </button>
+                    </footer>
+                  </section>
+
+                  <aside class="injection-preview-card">
+                    <header>
+                      <div>
+                        <span>实际注入预览</span>
+                        <small>新建 Agent 时将追加到 System Prompt 末尾</small>
+                      </div>
+                      <span class="injection-state" :class="{ off: !injectionEnabled }">
+                        <i /> {{ injectionEnabled ? 'ACTIVE' : 'PAUSED' }}
+                      </span>
+                    </header>
+                    <div class="injection-preview-body" :class="{ empty: !injectionDraft.trim() }">
+                      <template v-if="injectionDraft.trim()">
+                        <span class="injection-heading">## Web 文档注入</span>
+                        <p class="injection-preface">
+                          以下内容由运营人员通过 SupplyAgent Web 工作台维护,用于补充业务知识与判断背景。
+                        </p>
+                        <pre>{{ injectionDraft }}</pre>
+                      </template>
+                      <template v-else>
+                        <span class="empty-injection-icon">+</span>
+                        <strong>尚未添加注入文档</strong>
+                        <small>左侧输入内容后,这里会实时展示实际注入效果</small>
+                      </template>
+                    </div>
+                  </aside>
+                </div>
+
+                <div v-if="injectionSaveMessage || injectionSaveError" class="injection-message" :class="{ error: injectionSaveError }">
+                  <i />
+                  {{ injectionSaveError || injectionSaveMessage }}
+                </div>
+              </section>
+
               <section v-show="agentDetailTab === 'tools'" class="tools-panel">
                 <div class="detail-content-head">
                   <div>
@@ -1963,7 +2126,7 @@ function pollRun(runId: string) {
 .detail-tabs {
   display: grid;
   min-height: 67px;
-  grid-template-columns: 1fr 1fr;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
   padding: 0 28px;
   border-bottom: 1px solid #e2e5ec;
   background: #fff;
@@ -2020,6 +2183,7 @@ function pollRun(runId: string) {
 }
 
 .prompt-panel,
+.injection-panel,
 .tools-panel {
   display: flex;
   height: 100%;
@@ -2027,6 +2191,57 @@ function pollRun(runId: string) {
   flex-direction: column;
 }
 
+.injection-panel {
+  position: relative;
+}
+
+.prompt-injection-notice {
+  display: grid;
+  min-height: 44px;
+  grid-template-columns: auto 1fr auto;
+  align-items: center;
+  gap: 13px;
+  margin: 13px 28px 0;
+  padding: 0 13px;
+  border: 1px solid #e0e2ef;
+  border-radius: 9px;
+  background: linear-gradient(90deg, var(--detail-soft), #fafafd);
+  color: #596173;
+  text-align: left;
+  cursor: pointer;
+}
+
+.prompt-injection-notice > span {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  color: var(--detail-agent);
+  font-size: 8px;
+  font-weight: 800;
+}
+
+.prompt-injection-notice > span.disabled {
+  color: #959ba7;
+}
+
+.prompt-injection-notice > span i {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+  box-shadow: 0 0 0 3px rgba(92, 99, 128, 0.08);
+}
+
+.prompt-injection-notice > small {
+  color: #8e95a4;
+  font-size: 8px;
+}
+
+.prompt-injection-notice > b {
+  color: var(--detail-agent);
+  font-size: 15px;
+}
+
 .detail-content-head {
   display: flex;
   min-height: 61px;
@@ -2075,6 +2290,306 @@ function pollRun(runId: string) {
   letter-spacing: 0.08em;
 }
 
+.injection-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  color: #6d7585;
+  font-size: 8px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.injection-toggle input {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+}
+
+.injection-toggle > span {
+  position: relative;
+  width: 34px;
+  height: 19px;
+  border-radius: 999px;
+  background: #c9ced8;
+  transition: background 0.18s;
+}
+
+.injection-toggle > span i {
+  position: absolute;
+  top: 3px;
+  left: 3px;
+  width: 13px;
+  height: 13px;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 2px 5px rgba(24, 30, 44, 0.18);
+  transition: transform 0.18s;
+}
+
+.injection-toggle input:checked + span {
+  background: var(--detail-agent);
+}
+
+.injection-toggle input:checked + span i {
+  transform: translateX(15px);
+}
+
+.injection-workspace {
+  display: grid;
+  min-height: 0;
+  grid-template-columns: minmax(0, 1.08fr) minmax(330px, 0.92fr);
+  gap: 14px;
+  flex: 1;
+  padding: 16px 20px 20px;
+  overflow: hidden;
+}
+
+.injection-editor-card,
+.injection-preview-card {
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid #e0e3ea;
+  border-radius: 13px;
+  background: #fff;
+  box-shadow: 0 10px 28px rgba(36, 43, 62, 0.035);
+}
+
+.injection-editor-card > header,
+.injection-preview-card > header {
+  display: flex;
+  min-height: 61px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 14px;
+  padding: 0 16px;
+  flex-shrink: 0;
+  border-bottom: 1px solid #e8eaf0;
+  background: #fbfbfd;
+}
+
+.injection-editor-card header span,
+.injection-preview-card header span {
+  display: block;
+  color: #4b5364;
+  font-size: 10px;
+  font-weight: 800;
+}
+
+.injection-editor-card header small,
+.injection-preview-card header small {
+  display: block;
+  margin-top: 4px;
+  color: #9ca2ae;
+  font-size: 7px;
+}
+
+.injection-editor-card header code {
+  padding: 5px 7px;
+  border-radius: 5px;
+  background: var(--detail-soft);
+  color: var(--detail-agent);
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 6px;
+  font-weight: 900;
+  letter-spacing: 0.1em;
+}
+
+.injection-editor-card textarea {
+  min-height: 0;
+  flex: 1;
+  resize: none;
+  padding: 17px;
+  border: 0;
+  outline: none;
+  background:
+    linear-gradient(rgba(104, 111, 139, 0.035) 1px, transparent 1px),
+    #fff;
+  background-size: 100% 26px;
+  color: #465064;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'PingFang SC', monospace;
+  font-size: 10px;
+  line-height: 26px;
+}
+
+.injection-editor-card textarea::placeholder {
+  color: #afb4be;
+}
+
+.injection-editor-card > footer {
+  display: flex;
+  min-height: 57px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 0 14px;
+  flex-shrink: 0;
+  border-top: 1px solid #e8eaf0;
+  background: #fbfbfd;
+}
+
+.injection-editor-card > footer > span {
+  color: #999fac;
+  font-size: 7px;
+}
+
+.injection-editor-card > footer button {
+  display: inline-flex;
+  min-width: 88px;
+  height: 32px;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  padding: 0 12px;
+  border: 1px solid var(--detail-agent);
+  border-radius: 7px;
+  background: var(--detail-agent);
+  color: #fff;
+  font-size: 8px;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.injection-editor-card > footer button:disabled {
+  border-color: #d8dce4;
+  background: #eef0f4;
+  color: #9aa1ae;
+  cursor: default;
+}
+
+.save-spinner {
+  width: 11px;
+  height: 11px;
+  border: 2px solid rgba(255, 255, 255, 0.35);
+  border-top-color: #fff;
+  border-radius: 50%;
+  animation: spin 0.75s linear infinite;
+}
+
+.injection-state {
+  display: inline-flex !important;
+  align-items: center;
+  gap: 6px;
+  color: #23946e !important;
+  font-size: 6px !important;
+  letter-spacing: 0.1em;
+}
+
+.injection-state.off {
+  color: #999fac !important;
+}
+
+.injection-state i {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+  box-shadow: 0 0 0 3px rgba(35, 148, 110, 0.1);
+}
+
+.injection-preview-body {
+  min-height: 0;
+  flex: 1;
+  overflow: auto;
+  padding: 19px;
+  background:
+    radial-gradient(circle at 100% 0%, var(--detail-soft), transparent 38%),
+    #fff;
+}
+
+.injection-preview-body.empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  text-align: center;
+}
+
+.injection-heading {
+  display: block;
+  color: var(--detail-agent);
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 10px;
+  font-weight: 800;
+}
+
+.injection-preface {
+  margin: 10px 0 16px;
+  padding: 10px 12px;
+  border-left: 2px solid var(--detail-agent);
+  background: var(--detail-soft);
+  color: #70788a;
+  font-size: 8px;
+  line-height: 1.65;
+}
+
+.injection-preview-body pre {
+  margin: 0;
+  color: #515b6e;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'PingFang SC', monospace;
+  font-size: 9px;
+  line-height: 1.75;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.empty-injection-icon {
+  display: grid;
+  width: 42px;
+  height: 42px;
+  place-items: center;
+  border-radius: 12px;
+  background: var(--detail-soft);
+  color: var(--detail-agent);
+  font-size: 20px;
+}
+
+.injection-preview-body.empty strong {
+  margin-top: 12px;
+  color: #687082;
+  font-size: 10px;
+}
+
+.injection-preview-body.empty small {
+  max-width: 230px;
+  margin-top: 5px;
+  color: #a0a6b2;
+  font-size: 7px;
+  line-height: 1.6;
+}
+
+.injection-message {
+  position: absolute;
+  right: 30px;
+  bottom: 25px;
+  display: flex;
+  min-height: 35px;
+  align-items: center;
+  gap: 8px;
+  padding: 0 12px;
+  border: 1px solid #bfe3d5;
+  border-radius: 8px;
+  background: #effaf6;
+  color: #277a61;
+  font-size: 8px;
+  box-shadow: 0 9px 24px rgba(29, 68, 55, 0.1);
+}
+
+.injection-message.error {
+  border-color: #efcbc8;
+  background: #fff4f3;
+  color: #aa514c;
+}
+
+.injection-message i {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+}
+
 .prompt-document {
   min-height: 0;
   flex: 1;
@@ -2690,6 +3205,14 @@ function pollRun(runId: string) {
     height: calc(100vh - 28px);
     border-radius: 17px;
   }
+  .injection-workspace {
+    grid-template-columns: 1fr;
+    overflow: auto;
+  }
+  .injection-editor-card,
+  .injection-preview-card {
+    min-height: 430px;
+  }
   .detail-tool-list { grid-template-columns: 1fr; }
   .operations-grid { grid-template-columns: 1fr; }
   .section-heading { align-items: flex-start; flex-direction: column; }
@@ -2731,7 +3254,27 @@ function pollRun(runId: string) {
     white-space: nowrap;
   }
   .detail-tabs { padding: 0 17px; }
+  .detail-tabs span { font-size: 7px; }
+  .detail-tabs small { font-size: 6px; }
   .detail-content-head { padding: 0 17px; }
+  .injection-content-head {
+    align-items: flex-start;
+    padding-top: 10px;
+    padding-bottom: 10px;
+    flex-direction: column;
+  }
+  .injection-workspace {
+    gap: 10px;
+    padding: 10px;
+  }
+  .injection-editor-card,
+  .injection-preview-card { min-height: 410px; }
+  .injection-editor-card > footer {
+    align-items: flex-start;
+    padding: 10px 12px;
+    flex-direction: column;
+  }
+  .prompt-injection-notice { margin-right: 17px; margin-left: 17px; }
   .prompt-document {
     padding: 20px 17px 40px;
     background: #fff;
@@ -2751,4 +3294,331 @@ function pollRun(runId: string) {
   .card-heading { align-items: flex-start; flex-direction: column; }
   .overview-footer { gap: 8px; flex-direction: column; }
 }
+
+/* Readability and contrast */
+.hero-kicker,
+.section-index {
+  color: #586273;
+  font-size: 11px;
+}
+
+.version {
+  color: #586273;
+  font-size: 10px;
+}
+
+.hero-copy > p,
+.section-heading > p,
+.pipeline-meta {
+  color: #4f596a;
+  font-size: 14px;
+}
+
+.float-card small,
+.agent-label {
+  color: #5f6878;
+  font-size: 11px;
+}
+
+.metric-label {
+  color: #596273;
+  font-size: 13px;
+}
+
+.metric-card > small {
+  color: #5f6878;
+  font-size: 12px;
+}
+
+.feature-eyebrow {
+  font-size: 11px;
+}
+
+.feature-card p {
+  color: #4f596a;
+  font-size: 13px;
+}
+
+.feature-card > small {
+  color: #596273;
+  font-size: 11px;
+}
+
+.system-entry span,
+.system-exit span {
+  color: #4f596a;
+  font-size: 10px;
+}
+
+.system-entry strong,
+.system-exit strong {
+  color: #30394a;
+  font-size: 12px;
+}
+
+.agent-card {
+  min-height: 690px;
+  border-color: #d2d7e1;
+  background: #fff;
+}
+
+.agent-index {
+  font-size: 12px;
+}
+
+.agent-identity h3 {
+  font-size: 18px;
+}
+
+.agent-identity small {
+  color: #596273;
+  font-size: 11px;
+}
+
+.agent-status {
+  color: #596273;
+  font-size: 10px;
+}
+
+.agent-core-visual small {
+  color: #5f6878;
+  font-size: 9px;
+}
+
+.agent-description {
+  color: #465164;
+  font-size: 13px;
+}
+
+.mini-title > span {
+  font-size: 10px;
+}
+
+.mini-title > small {
+  color: #5f6878;
+  font-size: 11px;
+}
+
+.principle-list span {
+  color: #384355;
+  font-size: 12px;
+}
+
+.tool-cloud span {
+  min-height: 28px;
+  border-color: #d8dce5;
+  background: #f6f7fa;
+  color: #4f596a;
+  font-size: 11px;
+}
+
+.agent-detail-trigger span {
+  font-size: 12px;
+}
+
+.agent-detail-trigger small {
+  color: #5f6878;
+  font-size: 9px;
+}
+
+.agent-io > span {
+  color: #30394a;
+  font-size: 11px;
+}
+
+.agent-io small {
+  color: #5f6878;
+  font-size: 10px;
+}
+
+.foundation-label,
+.agent-foundation > small {
+  color: #c2c8d4;
+  font-size: 10px;
+}
+
+.agent-foundation > div span {
+  color: #f3f4f6;
+  font-size: 11px;
+}
+
+.detail-agent-identity small {
+  color: var(--detail-agent);
+  font-size: 10px;
+}
+
+.detail-agent-identity code {
+  color: #596273;
+  font-size: 11px;
+}
+
+.detail-tabs span {
+  font-size: 11px;
+}
+
+.detail-tabs small {
+  color: #5f6878;
+  font-size: 10px;
+}
+
+.detail-content-head > div > span {
+  color: #374151;
+  font-size: 11px;
+}
+
+.detail-content-head > div > small {
+  color: #5f6878;
+  font-size: 11px;
+}
+
+.detail-content-head > button,
+.detail-content-head > strong {
+  font-size: 11px;
+}
+
+.prompt-injection-notice > span,
+.prompt-injection-notice > small {
+  color: #465164;
+  font-size: 11px;
+}
+
+.injection-toggle {
+  color: #465164;
+  font-size: 11px;
+}
+
+.injection-editor-card header span,
+.injection-preview-card header span {
+  color: #30394a;
+  font-size: 13px;
+}
+
+.injection-editor-card header small,
+.injection-preview-card header small {
+  color: #5f6878;
+  font-size: 11px;
+}
+
+.injection-editor-card header code,
+.injection-state {
+  font-size: 10px !important;
+}
+
+.injection-editor-card textarea {
+  color: #30394a;
+  font-size: 13px;
+}
+
+.injection-editor-card textarea::placeholder {
+  color: #737d8e;
+}
+
+.injection-editor-card > footer > span,
+.injection-editor-card > footer button {
+  color: #4f596a;
+  font-size: 11px;
+}
+
+.injection-editor-card > footer button:not(:disabled) {
+  color: #fff;
+}
+
+.injection-preface,
+.injection-preview-body pre {
+  color: #3f4a5c;
+  font-size: 12px;
+}
+
+.injection-preview-body.empty strong {
+  color: #465164;
+  font-size: 12px;
+}
+
+.injection-preview-body.empty small,
+.injection-message {
+  color: #4f596a;
+  font-size: 11px;
+}
+
+.prompt-document h3 {
+  font-size: 17px;
+}
+
+.prompt-document p {
+  color: #3f4a5c;
+  font-size: 13px;
+}
+
+.detail-tool-title code {
+  color: #253044;
+  font-size: 12px;
+}
+
+.detail-tool-title span {
+  color: #596273;
+  font-size: 9px;
+}
+
+.detail-tool-main > p {
+  color: #465164;
+  font-size: 12px;
+}
+
+.parameter-list > span {
+  color: #374151;
+  font-size: 10px;
+}
+
+.parameter-list small {
+  color: #5f6878;
+  font-size: 9px;
+}
+
+.detail-state {
+  color: #4f596a;
+  font-size: 13px;
+}
+
+.stage-copy h3 {
+  font-size: 16px;
+}
+
+.stage-copy p {
+  color: #c0c7d3;
+  font-size: 12px;
+}
+
+.job-copy strong,
+.audit-copy strong {
+  font-size: 13px;
+}
+
+.job-copy small,
+.audit-copy small,
+.overview-footer {
+  color: #5f6878;
+  font-size: 11px;
+}
+
+.job-row button,
+.date-control,
+.date-control input {
+  font-size: 12px;
+}
+
+@media (max-width: 820px) {
+  .agent-card {
+    min-height: auto;
+  }
+}
+
+@media (max-width: 560px) {
+  .detail-tabs span {
+    font-size: 10px;
+  }
+
+  .detail-tabs small {
+    font-size: 9px;
+  }
+}
 </style>