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

fix(app): better tool call batching

Adam 1 месяц назад
Родитель
Сommit
e345b89ce5

+ 148 - 13
packages/ui/src/components/message-part.tsx

@@ -117,6 +117,7 @@ function createThrottledValue(getValue: () => string) {
   createEffect(() => {
     const next = getValue()
     const now = Date.now()
+
     const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
     if (remaining <= 0) {
       if (timeout) {
@@ -250,6 +251,126 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
 }
 
 const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
+const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
+
+function list<T>(value: T[] | undefined | null, fallback: T[]) {
+  if (Array.isArray(value)) return value
+  return fallback
+}
+
+function renderable(part: PartType) {
+  if (part.type === "tool") {
+    if (HIDDEN_TOOLS.has(part.tool)) return false
+    if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
+    return true
+  }
+  if (part.type === "text") return !!part.text?.trim()
+  if (part.type === "reasoning") return !!part.text?.trim()
+  return !!PART_MAPPING[part.type]
+}
+
+export function AssistantParts(props: {
+  messages: AssistantMessage[]
+  showAssistantCopyPartID?: string | null
+  working?: boolean
+}) {
+  const data = useData()
+  const emptyParts: PartType[] = []
+
+  const grouped = createMemo(() => {
+    const keys: string[] = []
+    const items: Record<
+      string,
+      { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }
+    > = {}
+    const push = (
+      key: string,
+      item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] },
+    ) => {
+      keys.push(key)
+      items[key] = item
+    }
+
+    const parts = props.messages.flatMap((message) =>
+      list(data.store.part?.[message.id], emptyParts)
+        .filter(renderable)
+        .map((part) => ({ message, part })),
+    )
+
+    let start = -1
+
+    const flush = (end: number) => {
+      if (start < 0) return
+      const first = parts[start]
+      const last = parts[end]
+      if (!first || !last) {
+        start = -1
+        return
+      }
+      push(`context:${first.part.id}`, {
+        type: "context",
+        parts: parts
+          .slice(start, end + 1)
+          .map((x) => x.part)
+          .filter((part): part is ToolPart => isContextGroupTool(part)),
+      })
+      start = -1
+    }
+
+    parts.forEach((item, index) => {
+      if (isContextGroupTool(item.part)) {
+        if (start < 0) start = index
+        return
+      }
+
+      flush(index - 1)
+      push(`part:${item.message.id}:${item.part.id}`, { type: "part", part: item.part, message: item.message })
+    })
+
+    flush(parts.length - 1)
+
+    return { keys, items }
+  })
+
+  const last = createMemo(() => grouped().keys.at(-1))
+
+  return (
+    <For each={grouped().keys}>
+      {(key) => {
+        const item = createMemo(() => grouped().items[key])
+        const ctx = createMemo(() => {
+          const value = item()
+          if (!value) return
+          if (value.type !== "context") return
+          return value
+        })
+        const part = createMemo(() => {
+          const value = item()
+          if (!value) return
+          if (value.type !== "part") return
+          return value
+        })
+        const tail = createMemo(() => last() === key)
+        return (
+          <>
+            <Show when={ctx()}>
+              {(entry) => <ContextToolGroup parts={entry().parts} busy={props.working && tail()} />}
+            </Show>
+            <Show when={part()}>
+              {(entry) => (
+                <Part
+                  part={entry().part}
+                  message={entry().message}
+                  showAssistantCopyPartID={props.showAssistantCopyPartID}
+                />
+              )}
+            </Show>
+          </>
+        )
+      }}
+    </For>
+  )
+}
 
 function isContextGroupTool(part: PartType): part is ToolPart {
   return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
@@ -390,6 +511,8 @@ export function AssistantMessageDisplay(props: {
     }
 
     parts.forEach((part, index) => {
+      if (!renderable(part)) return
+
       if (isContextGroupTool(part)) {
         if (start < 0) start = index
         return
@@ -408,31 +531,43 @@ export function AssistantMessageDisplay(props: {
     <For each={grouped().keys}>
       {(key) => {
         const item = createMemo(() => grouped().items[key])
+        const ctx = createMemo(() => {
+          const value = item()
+          if (!value) return
+          if (value.type !== "context") return
+          return value
+        })
+        const part = createMemo(() => {
+          const value = item()
+          if (!value) return
+          if (value.type !== "part") return
+          return value
+        })
         return (
-          <Show when={item()}>
-            {(value) => {
-              const entry = value()
-              if (entry.type === "context") return <ContextToolGroup parts={entry.parts} />
-              return (
+          <>
+            <Show when={ctx()}>{(entry) => <ContextToolGroup parts={entry().parts} />}</Show>
+            <Show when={part()}>
+              {(entry) => (
                 <Part
-                  part={entry.part}
+                  part={entry().part}
                   message={props.message}
                   showAssistantCopyPartID={props.showAssistantCopyPartID}
                 />
-              )
-            }}
-          </Show>
+              )}
+            </Show>
+          </>
         )
       }}
     </For>
   )
 }
 
-function ContextToolGroup(props: { parts: ToolPart[] }) {
+function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
   const i18n = useI18n()
   const [open, setOpen] = createSignal(false)
-  const pending = createMemo(() =>
-    props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
+  const pending = createMemo(
+    () =>
+      !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
   )
   const summary = createMemo(() => contextToolSummary(props.parts))
   const details = createMemo(() => summary().join(", "))
@@ -445,7 +580,7 @@ function ContextToolGroup(props: { parts: ToolPart[] }) {
             when={pending()}
             fallback={
               <span data-slot="context-tool-group-title">
-                <span data-slot="context-tool-group-label">Gathered context</span>
+                <span data-slot="context-tool-group-label">{i18n.t("ui.sessionTurn.status.gatheredContext")}</span>
                 <Show when={details().length}>
                   <span data-slot="context-tool-group-summary">{details()}</span>
                 </Show>

+ 7 - 18
packages/ui/src/components/session-turn.tsx

@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { createMemo, createSignal, For, ParentProps, Show } from "solid-js"
 import { Dynamic } from "solid-js/web"
-import { Message } from "./message-part"
+import { AssistantParts, Message } from "./message-part"
 import { Card } from "./card"
 import { Collapsible } from "./collapsible"
 import { DiffChanges } from "./diff-changes"
@@ -91,13 +91,6 @@ function visible(part: PartType) {
   return false
 }
 
-function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string | null }) {
-  const data = useData()
-  const emptyParts: PartType[] = []
-  const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
-  return <Message message={props.message} parts={msgParts()} showAssistantCopyPartID={props.showAssistantCopyPartID} />
-}
-
 export function SessionTurn(
   props: ParentProps<{
     sessionID: string
@@ -237,8 +230,7 @@ export function SessionTurn(
   const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
 
   const assistantCopyPartID = createMemo(() => {
-    if (!isLastUserMessage()) return null
-    if (status().type !== "idle") return null
+    if (working()) return null
     return showAssistantCopyPartID() ?? null
   })
   const assistantVisible = createMemo(() =>
@@ -281,14 +273,11 @@ export function SessionTurn(
                 </Show>
                 <Show when={assistantMessages().length > 0}>
                   <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
-                    <For each={assistantMessages()}>
-                      {(assistantMessage) => (
-                        <AssistantMessageItem
-                          message={assistantMessage}
-                          showAssistantCopyPartID={assistantCopyPartID()}
-                        />
-                      )}
-                    </For>
+                    <AssistantParts
+                      messages={assistantMessages()}
+                      showAssistantCopyPartID={assistantCopyPartID()}
+                      working={working()}
+                    />
                   </div>
                 </Show>
                 <Show when={edited() > 0}>

+ 2 - 1
packages/ui/src/i18n/ar.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "تفويض العمل",
   "ui.sessionTurn.status.planning": "تخطيط الخطوات التالية",
-  "ui.sessionTurn.status.gatheringContext": "جمع السياق",
+  "ui.sessionTurn.status.gatheringContext": "استكشاف...",
+  "ui.sessionTurn.status.gatheredContext": "تم الاستكشاف",
   "ui.sessionTurn.status.searchingCodebase": "البحث في قاعدة التعليمات البرمجية",
   "ui.sessionTurn.status.searchingWeb": "البحث في الويب",
   "ui.sessionTurn.status.makingEdits": "إجراء تعديلات",

+ 2 - 1
packages/ui/src/i18n/br.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Delegando trabalho",
   "ui.sessionTurn.status.planning": "Planejando próximos passos",
-  "ui.sessionTurn.status.gatheringContext": "Coletando contexto",
+  "ui.sessionTurn.status.gatheringContext": "Explorando...",
+  "ui.sessionTurn.status.gatheredContext": "Explorado",
   "ui.sessionTurn.status.searchingCodebase": "Pesquisando no código",
   "ui.sessionTurn.status.searchingWeb": "Pesquisando na web",
   "ui.sessionTurn.status.makingEdits": "Fazendo edições",

+ 2 - 1
packages/ui/src/i18n/bs.ts

@@ -37,7 +37,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Delegiranje posla",
   "ui.sessionTurn.status.planning": "Planiranje sljedećih koraka",
-  "ui.sessionTurn.status.gatheringContext": "Prikupljanje konteksta",
+  "ui.sessionTurn.status.gatheringContext": "Istraživanje...",
+  "ui.sessionTurn.status.gatheredContext": "Istraženo",
   "ui.sessionTurn.status.searchingCodebase": "Pretraživanje baze koda",
   "ui.sessionTurn.status.searchingWeb": "Pretraživanje weba",
   "ui.sessionTurn.status.makingEdits": "Pravljenje izmjena",

+ 2 - 1
packages/ui/src/i18n/da.ts

@@ -32,7 +32,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Delegerer arbejde",
   "ui.sessionTurn.status.planning": "Planlægger næste trin",
-  "ui.sessionTurn.status.gatheringContext": "Indsamler kontekst",
+  "ui.sessionTurn.status.gatheringContext": "Udforsker...",
+  "ui.sessionTurn.status.gatheredContext": "Udforsket",
   "ui.sessionTurn.status.searchingCodebase": "Søger i koden",
   "ui.sessionTurn.status.searchingWeb": "Søger på nettet",
   "ui.sessionTurn.status.makingEdits": "Laver ændringer",

+ 2 - 1
packages/ui/src/i18n/de.ts

@@ -36,7 +36,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Arbeit delegieren",
   "ui.sessionTurn.status.planning": "Nächste Schritte planen",
-  "ui.sessionTurn.status.gatheringContext": "Kontext sammeln",
+  "ui.sessionTurn.status.gatheringContext": "Erkunden...",
+  "ui.sessionTurn.status.gatheredContext": "Erkundet",
   "ui.sessionTurn.status.searchingCodebase": "Codebasis durchsuchen",
   "ui.sessionTurn.status.searchingWeb": "Web durchsuchen",
   "ui.sessionTurn.status.makingEdits": "Änderungen vornehmen",

+ 2 - 1
packages/ui/src/i18n/en.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Delegating work",
   "ui.sessionTurn.status.planning": "Planning next steps",
-  "ui.sessionTurn.status.gatheringContext": "Gathering context",
+  "ui.sessionTurn.status.gatheringContext": "Exploring...",
+  "ui.sessionTurn.status.gatheredContext": "Explored",
   "ui.sessionTurn.status.searchingCodebase": "Searching the codebase",
   "ui.sessionTurn.status.searchingWeb": "Searching the web",
   "ui.sessionTurn.status.makingEdits": "Making edits",

+ 2 - 1
packages/ui/src/i18n/es.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Delegando trabajo",
   "ui.sessionTurn.status.planning": "Planificando siguientes pasos",
-  "ui.sessionTurn.status.gatheringContext": "Recopilando contexto",
+  "ui.sessionTurn.status.gatheringContext": "Explorando...",
+  "ui.sessionTurn.status.gatheredContext": "Explorado",
   "ui.sessionTurn.status.searchingCodebase": "Buscando en la base de código",
   "ui.sessionTurn.status.searchingWeb": "Buscando en la web",
   "ui.sessionTurn.status.makingEdits": "Realizando ediciones",

+ 2 - 1
packages/ui/src/i18n/fr.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Délégation du travail",
   "ui.sessionTurn.status.planning": "Planification des prochaines étapes",
-  "ui.sessionTurn.status.gatheringContext": "Collecte du contexte",
+  "ui.sessionTurn.status.gatheringContext": "Exploration...",
+  "ui.sessionTurn.status.gatheredContext": "Exploré",
   "ui.sessionTurn.status.searchingCodebase": "Recherche dans la base de code",
   "ui.sessionTurn.status.searchingWeb": "Recherche sur le web",
   "ui.sessionTurn.status.makingEdits": "Application des modifications",

+ 2 - 1
packages/ui/src/i18n/ja.ts

@@ -32,7 +32,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "作業を委任中",
   "ui.sessionTurn.status.planning": "次のステップを計画中",
-  "ui.sessionTurn.status.gatheringContext": "コンテキストを収集中",
+  "ui.sessionTurn.status.gatheringContext": "探索中...",
+  "ui.sessionTurn.status.gatheredContext": "探索済み",
   "ui.sessionTurn.status.searchingCodebase": "コードベースを検索中",
   "ui.sessionTurn.status.searchingWeb": "ウェブを検索中",
   "ui.sessionTurn.status.makingEdits": "編集を実行中",

+ 2 - 1
packages/ui/src/i18n/ko.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "작업 위임 중",
   "ui.sessionTurn.status.planning": "다음 단계 계획 중",
-  "ui.sessionTurn.status.gatheringContext": "컨텍스트 수집 중",
+  "ui.sessionTurn.status.gatheringContext": "탐색 중...",
+  "ui.sessionTurn.status.gatheredContext": "탐색됨",
   "ui.sessionTurn.status.searchingCodebase": "코드베이스 검색 중",
   "ui.sessionTurn.status.searchingWeb": "웹 검색 중",
   "ui.sessionTurn.status.makingEdits": "편집 수행 중",

+ 2 - 1
packages/ui/src/i18n/no.ts

@@ -36,7 +36,8 @@ export const dict: Record<Keys, string> = {
 
   "ui.sessionTurn.status.delegating": "Delegerer arbeid",
   "ui.sessionTurn.status.planning": "Planlegger neste trinn",
-  "ui.sessionTurn.status.gatheringContext": "Samler inn kontekst",
+  "ui.sessionTurn.status.gatheringContext": "Utforsker...",
+  "ui.sessionTurn.status.gatheredContext": "Utforsket",
   "ui.sessionTurn.status.searchingCodebase": "Søker i kodebasen",
   "ui.sessionTurn.status.searchingWeb": "Søker på nettet",
   "ui.sessionTurn.status.makingEdits": "Gjør endringer",

+ 2 - 1
packages/ui/src/i18n/pl.ts

@@ -32,7 +32,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Delegowanie pracy",
   "ui.sessionTurn.status.planning": "Planowanie kolejnych kroków",
-  "ui.sessionTurn.status.gatheringContext": "Zbieranie kontekstu",
+  "ui.sessionTurn.status.gatheringContext": "Eksplorowanie...",
+  "ui.sessionTurn.status.gatheredContext": "Wyeksplorowano",
   "ui.sessionTurn.status.searchingCodebase": "Przeszukiwanie bazy kodu",
   "ui.sessionTurn.status.searchingWeb": "Przeszukiwanie sieci",
   "ui.sessionTurn.status.makingEdits": "Wprowadzanie zmian",

+ 2 - 1
packages/ui/src/i18n/ru.ts

@@ -32,7 +32,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "Делегирование работы",
   "ui.sessionTurn.status.planning": "Планирование следующих шагов",
-  "ui.sessionTurn.status.gatheringContext": "Сбор контекста",
+  "ui.sessionTurn.status.gatheringContext": "Исследование...",
+  "ui.sessionTurn.status.gatheredContext": "Исследовано",
   "ui.sessionTurn.status.searchingCodebase": "Поиск в кодовой базе",
   "ui.sessionTurn.status.searchingWeb": "Поиск в интернете",
   "ui.sessionTurn.status.makingEdits": "Внесение изменений",

+ 2 - 1
packages/ui/src/i18n/th.ts

@@ -33,7 +33,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "มอบหมายงาน",
   "ui.sessionTurn.status.planning": "วางแผนขั้นตอนถัดไป",
-  "ui.sessionTurn.status.gatheringContext": "รวบรวมบริบท",
+  "ui.sessionTurn.status.gatheringContext": "กำลังสำรวจ...",
+  "ui.sessionTurn.status.gatheredContext": "สำรวจแล้ว",
   "ui.sessionTurn.status.searchingCodebase": "กำลังค้นหาโค้ดเบส",
   "ui.sessionTurn.status.searchingWeb": "กำลังค้นหาบนเว็บ",
   "ui.sessionTurn.status.makingEdits": "กำลังแก้ไข",

+ 2 - 1
packages/ui/src/i18n/zh.ts

@@ -37,7 +37,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "正在委派工作",
   "ui.sessionTurn.status.planning": "正在规划下一步",
-  "ui.sessionTurn.status.gatheringContext": "正在收集上下文",
+  "ui.sessionTurn.status.gatheringContext": "正在探索...",
+  "ui.sessionTurn.status.gatheredContext": "已探索",
   "ui.sessionTurn.status.searchingCodebase": "正在搜索代码库",
   "ui.sessionTurn.status.searchingWeb": "正在搜索网页",
   "ui.sessionTurn.status.makingEdits": "正在修改",

+ 2 - 1
packages/ui/src/i18n/zht.ts

@@ -37,7 +37,8 @@ export const dict = {
 
   "ui.sessionTurn.status.delegating": "正在委派工作",
   "ui.sessionTurn.status.planning": "正在規劃下一步",
-  "ui.sessionTurn.status.gatheringContext": "正在收集上下文",
+  "ui.sessionTurn.status.gatheringContext": "正在探索...",
+  "ui.sessionTurn.status.gatheredContext": "已探索",
   "ui.sessionTurn.status.searchingCodebase": "正在搜尋程式碼庫",
   "ui.sessionTurn.status.searchingWeb": "正在搜尋網頁",
   "ui.sessionTurn.status.makingEdits": "正在修改",