Skip to content

[WIP] 新前端工作台搭建#1278

Draft
Ferry-200 wants to merge 102 commits intovolcengine:mainfrom
Ferry-200:new-frontend
Draft

[WIP] 新前端工作台搭建#1278
Ferry-200 wants to merge 102 commits intovolcengine:mainfrom
Ferry-200:new-frontend

Conversation

@Ferry-200
Copy link
Copy Markdown

@Ferry-200 Ferry-200 commented Apr 7, 2026

Description

相关飞书内讨论后续补

Related Issue

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test update

Changes Made

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this on the following platforms:
    • Linux
    • macOS
    • Windows

Checklist

  • My code follows the project's coding style
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

Screenshots (if applicable)

Additional Notes

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 7, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🏅 Score: 75
🧪 No relevant tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Add API client generation scripts

Relevant files:

  • web-studio/script/gen-server-client/gen-server-client.sh
  • web-studio/script/gen-server-client/polishOpId.js

Sub-PR theme: Add UI component library

Relevant files:

  • web-studio/src/components/ui/*.tsx

Sub-PR theme: Add legacy console pages

Relevant files:

  • web-studio/src/components/legacy/**/*.tsx

⚡ Recommended focus areas for review

Hardcoded Server URL

The script uses a hardcoded URL http://127.0.0.1:1933/openapi.json which may not be flexible for different environments.

openapi-format "http://127.0.0.1:1933/openapi.json" --configFile "./script/gen-server-client/oaf-generate-conf.json"
Missing Error Boundary

The component uses multiple mutations and queries without an error boundary, which could lead to unhandled errors crashing the entire app.

export function DataLegacyPage() {
  const [draftUri, setDraftUri] = useState('viking://')
  const [currentUri, setCurrentUri] = useState('viking://')
  const [findLimit, setFindLimit] = useState('10')
  const [findQuery, setFindQuery] = useState('')
  const [findRows, setFindRows] = useState<Array<FindRow>>([])
  const [findTargetUri, setFindTargetUri] = useState('')
  const [latestResult, setLatestResult] = useState<LatestResult | null>(null)
  const [memoryInput, setMemoryInput] = useState('')
  const [resourceExclude, setResourceExclude] = useState('')
  const [resourceFile, setResourceFile] = useState<File | null>(null)
  const [resourceIgnoreDirs, setResourceIgnoreDirs] = useState('')
  const [resourceInclude, setResourceInclude] = useState('')
  const [resourceInstruction, setResourceInstruction] = useState('')
  const [resourceMode, setResourceMode] = useState<'path' | 'upload'>('path')
  const [resourcePath, setResourcePath] = useState('')
  const [resourceReason, setResourceReason] = useState('')
  const [resourceStrict, setResourceStrict] = useState(true)
  const [resourceTargetUri, setResourceTargetUri] = useState('')
  const [resourceTimeout, setResourceTimeout] = useState('')
  const [resourceUploadMedia, setResourceUploadMedia] = useState(true)
  const [resourceWait, setResourceWait] = useState(false)

  useEffect(() => {
    applyLegacyConnectionSettings(loadLegacyConnectionSettings())
  }, [])

  const filesystemQuery = useQuery({
    queryKey: ['legacy-data-filesystem', currentUri],
    queryFn: async () => {
      const result = await getOvResult(
        getFsLs({
          query: {
            show_all_hidden: true,
            uri: normalizeDirUri(currentUri),
          },
        }),
      )

      return normalizeFsEntries(result, normalizeDirUri(currentUri))
    },
  })

  useEffect(() => {
    setDraftUri(currentUri)
  }, [currentUri])

  const statMutation = useMutation({
    mutationFn: async (uri: string) =>
      getOvResult(
        getFsStat({
          query: { uri },
        }),
      ),
    onError: (error) => {
      setLatestResult({ title: 'Stat Error', value: getErrorMessage(error) })
    },
    onSuccess: (result, uri) => {
      setLatestResult({ title: `Stat: ${uri}`, value: result })
    },
  })

  const readMutation = useMutation({
    mutationFn: async (uri: string) => {
      const result = await getOvResult(
        getContentRead({
          query: {
            limit: -1,
            offset: 0,
            uri,
          },
        }),
      )

      return normalizeReadContent(result)
    },
    onError: (error) => {
      setLatestResult({ title: 'Read Error', value: getErrorMessage(error) })
    },
    onSuccess: (result, uri) => {
      setLatestResult({ title: `Read: ${uri}`, value: result || '(empty file)' })
    },
  })

  const findMutation = useMutation({
    mutationFn: async () => {
      const parsedLimit = Number.parseInt(findLimit, 10)
      const result = await getOvResult(
        postSearchFind({
          body: {
            limit: Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined,
            query: findQuery.trim(),
            target_uri: findTargetUri.trim() || undefined,
          },
        }),
      )

      return {
        rows: normalizeFindRows(result),
        result,
      }
    },
    onError: (error) => {
      setLatestResult({ title: 'Find Error', value: getErrorMessage(error) })
    },
    onSuccess: ({ rows, result }) => {
      setFindRows(rows)
      setLatestResult({ title: 'Find Result', value: result })
    },
  })

  const addResourceMutation = useMutation({
    mutationFn: async () => {
      const request: AddResourceRequest = {
        directly_upload_media: resourceUploadMedia,
        exclude: resourceExclude.trim() || undefined,
        ignore_dirs: resourceIgnoreDirs.trim() || undefined,
        include: resourceInclude.trim() || undefined,
        instruction: resourceInstruction.trim() || undefined,
        reason: resourceReason.trim() || undefined,
        strict: resourceStrict,
        timeout:
          resourceTimeout.trim() && Number.isFinite(Number(resourceTimeout))
            ? Number(resourceTimeout)
            : undefined,
        to: resourceTargetUri.trim() || undefined,
        wait: resourceWait,
      }

      if (resourceMode === 'path') {
        if (!resourcePath.trim()) {
          throw new Error('请输入 OpenViking 可访问的资源路径。')
        }

        request.path = resourcePath.trim()
        return getOvResult(postResources({ body: request }))
      }

      if (!resourceFile) {
        throw new Error('请先选择需要上传的文件。')
      }

      const uploadResult = await getOvResult(
        postResourcesTempUpload({
          body: {
            file: resourceFile,
            telemetry: true,
          },
        }),
      )

      const tempFileId = isRecord(uploadResult) ? uploadResult.temp_file_id : undefined
      if (typeof tempFileId !== 'string' || !tempFileId.trim()) {
        throw new Error('临时上传成功,但未返回 temp_file_id。')
      }

      request.temp_file_id = tempFileId

      return {
        addResource: await getOvResult(postResources({ body: request })),
        upload: uploadResult,
      }
    },
    onError: (error) => {
      setLatestResult({ title: 'Add Resource Error', value: getErrorMessage(error) })
    },
    onSuccess: (result) => {
      setLatestResult({ title: 'Add Resource Result', value: result })
    },
  })

  const addMemoryMutation = useMutation({
    mutationFn: async () => {
      const text = memoryInput.trim()
      if (!text) {
        throw new Error('请输入要提交的 memory 内容。')
      }

      let messages: Array<AddMessageRequest>
      try {
        const parsed = JSON.parse(text) as unknown
        messages = Array.isArray(parsed)
          ? (parsed as Array<AddMessageRequest>)
          : [{ content: text, role: 'user' }]
      } catch {
        messages = [{ content: text, role: 'user' }]
      }

      const session = await getOvResult(postSessions({ body: {} }))
      const sessionId = isRecord(session) ? session.session_id : undefined
      if (typeof sessionId !== 'string' || !sessionId.trim()) {
        throw new Error('创建 session 失败,未返回 session_id。')
      }

      for (const message of messages) {
        await getOvResult(
          postSessionIdMessages({
            body: message,
            path: { session_id: sessionId },
          }),
        )
      }

      const commit = await getOvResult(
        postSessionIdCommit({
          path: { session_id: sessionId },
        }),
      )

      return {
        commit,
        session,
      }
    },
    onError: (error) => {
      setLatestResult({ title: 'Add Memory Error', value: getErrorMessage(error) })
    },
    onSuccess: (result) => {
      setLatestResult({ title: 'Add Memory Result', value: result })
    },
  })

  const findColumns = collectFindColumns(findRows)
  const activeError =
    filesystemQuery.error ||
    findMutation.error ||
    addResourceMutation.error ||
    addMemoryMutation.error ||
    readMutation.error ||
    statMutation.error

  return (
    <LegacyPageShell
      description="复刻旧版数据操作入口,但请求直接走真实后端接口,加载和提交状态统一由 TanStack Query 管理。"
      section="data"
      title="旧控制台数据面板"
    >
        {activeError ? (
          <Alert variant="destructive">
            <Database className="size-4" />
            <AlertTitle>请求失败</AlertTitle>
            <AlertDescription>{getErrorMessage(activeError)}</AlertDescription>
          </Alert>
        ) : null}

        <div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(340px,0.7fr)]">
          <div className="grid gap-6">
            <Card>
              <CardHeader>
                <CardTitle>FileSystem</CardTitle>
                <CardDescription>基础列表浏览,支持进入目录、读取文件和查看 stat。</CardDescription>
              </CardHeader>
              <CardContent className="space-y-4">
                <div className="flex flex-col gap-3 lg:flex-row">
                  <Input value={draftUri} onChange={(event) => setDraftUri(event.target.value)} />
                  <div className="flex flex-wrap gap-2">
                    <Button onClick={() => setCurrentUri(normalizeDirUri(draftUri))}>进入</Button>
                    <Button variant="outline" onClick={() => setCurrentUri(parentUri(currentUri))}>上一级</Button>
                    <Button variant="outline" onClick={() => filesystemQuery.refetch()}>刷新</Button>
                  </div>
                </div>

                <div className="rounded-2xl border border-border/70">
                  <Table>
                    <TableHeader>
                      <TableRow>
                        <TableHead>uri</TableHead>
                        <TableHead>size</TableHead>
                        <TableHead>isDir</TableHead>
                        <TableHead>modTime</TableHead>
                        <TableHead>abstract</TableHead>
                        <TableHead className="text-right">actions</TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {filesystemQuery.isLoading ? (
                        <TableRow>
                          <TableCell colSpan={6}>正在加载目录...</TableCell>
                        </TableRow>
                      ) : null}
                      {!filesystemQuery.isLoading && !filesystemQuery.data?.length ? (
                        <TableRow>
                          <TableCell colSpan={6}>当前目录没有内容。</TableCell>
                        </TableRow>
                      ) : null}
                      {filesystemQuery.data?.map((entry) => (
                        <TableRow key={entry.uri}>
                          <TableCell>
                            <button
                              className="text-left font-medium text-foreground hover:text-primary"
                              type="button"
                              onClick={() => {
                                if (entry.isDir) {
                                  setCurrentUri(normalizeDirUri(entry.uri))
                                  return
                                }
                                readMutation.mutate(entry.uri)
                              }}
                            >
                              {entry.uri}
                            </button>
                          </TableCell>
                          <TableCell>{entry.size || '-'}</TableCell>
                          <TableCell>{entry.isDir ? 'true' : 'false'}</TableCell>
                          <TableCell>{entry.modTime || '-'}</TableCell>
                          <TableCell className="max-w-[240px] whitespace-normal text-muted-foreground">{entry.abstract || '-'}</TableCell>
                          <TableCell>
                            <div className="flex justify-end gap-2">
                              {!entry.isDir ? (
                                <Button size="sm" variant="outline" onClick={() => readMutation.mutate(entry.uri)}>
                                  读取
                                </Button>
                              ) : null}
                              <Button size="sm" variant="outline" onClick={() => statMutation.mutate(entry.uri)}>
                                Stat
                              </Button>
                            </div>
                          </TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
              </CardContent>
            </Card>

            <Card>
              <CardHeader>
                <CardTitle>Find</CardTitle>
                <CardDescription>沿用旧版入口,但直接调用真实的 /api/v1/search/find。</CardDescription>
              </CardHeader>
              <CardContent className="space-y-4">
                <div className="grid gap-4 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px]">
                  <div className="space-y-2">
                    <Label htmlFor="legacy-find-query">Query</Label>
                    <Input id="legacy-find-query" value={findQuery} onChange={(event) => setFindQuery(event.target.value)} />
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="legacy-find-target">Target URI</Label>
                    <Input id="legacy-find-target" placeholder="viking://resources/" value={findTargetUri} onChange={(event) => setFindTargetUri(event.target.value)} />
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="legacy-find-limit">Limit</Label>
                    <Input id="legacy-find-limit" value={findLimit} onChange={(event) => setFindLimit(event.target.value)} />
                  </div>
                </div>

                <Button
                  onClick={() => {
                    if (!findQuery.trim()) {
                      setLatestResult({ title: 'Find Error', value: 'Query 不能为空。' })
                      return
                    }
                    findMutation.mutate()
                  }}
                >
                  {findMutation.isPending ? '查询中...' : '运行 Find'}
                </Button>

                <div className="rounded-2xl border border-border/70">
                  <Table>
                    <TableHeader>
                      <TableRow>
                        {findColumns.map((column) => (
                          <TableHead key={column}>{column}</TableHead>
                        ))}
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {!findRows.length ? (
                        <TableRow>
                          <TableCell colSpan={findColumns.length}>暂无结果。</TableCell>
                        </TableRow>
                      ) : null}
                      {findRows.map((row, index) => (
                        <TableRow key={`${index}-${String(row.uri || row.id || row.value || 'row')}`}>
                          {findColumns.map((column) => (
                            <TableCell key={column} className="max-w-[260px] whitespace-normal align-top">
                              {typeof row[column] === 'string' ? String(row[column]) : formatResult(row[column] ?? '-')}
                            </TableCell>
                          ))}
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
              </CardContent>
            </Card>

            <div className="grid gap-6 lg:grid-cols-2">
              <Card>
                <CardHeader>
                  <CardTitle>Add Resource</CardTitle>
                  <CardDescription>保留旧版 path/upload 两种入口,但参数映射到真实后端。</CardDescription>
                </CardHeader>
                <CardContent className="space-y-4">
                  <div className="flex flex-wrap gap-2">
                    <Button variant={resourceMode === 'path' ? 'default' : 'outline'} onClick={() => setResourceMode('path')}>
                      Path
                    </Button>
                    <Button variant={resourceMode === 'upload' ? 'default' : 'outline'} onClick={() => setResourceMode('upload')}>
                      Upload
                    </Button>
                  </div>

                  {resourceMode === 'path' ? (
                    <div className="space-y-2">
                      <Label htmlFor="legacy-resource-path">Source Path</Label>
                      <Input id="legacy-resource-path" placeholder="/abs/path/on/server/or/repo" value={resourcePath} onChange={(event) => setResourcePath(event.target.value)} />
                    </div>
                  ) : (
                    <div className="space-y-2">
                      <Label htmlFor="legacy-resource-file">Upload File</Label>
                      <Input id="legacy-resource-file" type="file" onChange={(event) => setResourceFile(event.target.files?.[0] || null)} />
                    </div>
                  )}

                  <div className="space-y-2">
                    <Label htmlFor="legacy-resource-target">Target URI</Label>
                    <Input id="legacy-resource-target" placeholder="viking://resources/my-resource" value={resourceTargetUri} onChange={(event) => setResourceTargetUri(event.target.value)} />
                  </div>

                  <div className="grid gap-3 md:grid-cols-2">
                    <div className="space-y-2">
                      <Label htmlFor="legacy-resource-timeout">Timeout</Label>
                      <Input id="legacy-resource-timeout" placeholder="30" value={resourceTimeout} onChange={(event) => setResourceTimeout(event.target.value)} />
                    </div>
                    <div className="space-y-2">
                      <Label htmlFor="legacy-resource-ignore">Ignore Dirs</Label>
                      <Input id="legacy-resource-ignore" placeholder=".git,node_modules" value={resourceIgnoreDirs} onChange={(event) => setResourceIgnoreDirs(event.target.value)} />
                    </div>
                    <div className="space-y-2">
                      <Label htmlFor="legacy-resource-include">Include</Label>
                      <Input id="legacy-resource-include" placeholder="*.md,*.txt" value={resourceInclude} onChange={(event) => setResourceInclude(event.target.value)} />
                    </div>
                    <div className="space-y-2">
                      <Label htmlFor="legacy-resource-exclude">Exclude</Label>
                      <Input id="legacy-resource-exclude" placeholder="*.log,*.tmp" value={resourceExclude} onChange={(event) => setResourceExclude(event.target.value)} />
                    </div>
                  </div>

                  <div className="space-y-2">
                    <Label htmlFor="legacy-resource-reason">Reason</Label>
                    <Textarea id="legacy-resource-reason" rows={2} value={resourceReason} onChange={(event) => setResourceReason(event.target.value)} />
                  </div>

                  <div className="space-y-2">
                    <Label htmlFor="legacy-resource-instruction">Instruction</Label>
                    <Textarea id="legacy-resource-instruction" rows={3} value={resourceInstruction} onChange={(event) => setResourceInstruction(event.target.value)} />
                  </div>

                  <div className="grid gap-3 sm:grid-cols-3">
                    <Label htmlFor="legacy-resource-wait" className="rounded-2xl border border-border/70 bg-muted/20 p-3">
                      <Checkbox checked={resourceWait} onCheckedChange={(checked) => setResourceWait(Boolean(checked))} id="legacy-resource-wait" />
                      wait
                    </Label>
                    <Label htmlFor="legacy-resource-strict" className="rounded-2xl border border-border/70 bg-muted/20 p-3">
                      <Checkbox checked={resourceStrict} onCheckedChange={(checked) => setResourceStrict(Boolean(checked))} id="legacy-resource-strict" />
                      strict
                    </Label>
                    <Label htmlFor="legacy-resource-upload-media" className="rounded-2xl border border-border/70 bg-muted/20 p-3">
                      <Checkbox checked={resourceUploadMedia} onCheckedChange={(checked) => setResourceUploadMedia(Boolean(checked))} id="legacy-resource-upload-media" />
                      directly_upload_media
                    </Label>
                  </div>

                  <Button onClick={() => addResourceMutation.mutate()}>
                    {addResourceMutation.isPending ? '提交中...' : 'Add Resource'}
                  </Button>
                </CardContent>
              </Card>

              <Card>
                <CardHeader>
                  <CardTitle>Add Memory</CardTitle>
                  <CardDescription>按照旧版流程创建 session、写入消息、再提交 commit。</CardDescription>
                </CardHeader>
                <CardContent className="space-y-4">
                  <Textarea
                    rows={12}
                    placeholder='纯文本会按单条 user message 处理;也可以输入 JSON 数组 [{"role":"user","content":"..."}]'
                    value={memoryInput}
                    onChange={(event) => setMemoryInput(event.target.value)}
                  />
                  <Button onClick={() => addMemoryMutation.mutate()}>
                    {addMemoryMutation.isPending ? '提交中...' : 'Add Memory'}
                  </Button>
                </CardContent>
              </Card>
            </div>
          </div>

          <div className="grid gap-6">
            <Card>
              <CardHeader>
                <CardTitle>Latest Result</CardTitle>
                <CardDescription>代替旧控制台右侧输出区,展示最近一次请求结果。</CardDescription>
              </CardHeader>
              <CardContent>
                <div className="mb-3 flex flex-wrap gap-2">
                  <Badge variant="secondary">{latestResult?.title || 'Idle'}</Badge>
                  {filesystemQuery.isFetching ? <Badge variant="outline">filesystem loading</Badge> : null}
                  {readMutation.isPending ? <Badge variant="outline">reading</Badge> : null}
                  {statMutation.isPending ? <Badge variant="outline">stat</Badge> : null}
                  {findMutation.isPending ? <Badge variant="outline">find</Badge> : null}
                  {addResourceMutation.isPending ? <Badge variant="outline">resource</Badge> : null}
                  {addMemoryMutation.isPending ? <Badge variant="outline">memory</Badge> : null}
                </div>
                <pre className="max-h-[70vh] overflow-auto rounded-2xl border border-border/70 bg-muted/20 p-4 text-xs leading-6 whitespace-pre-wrap break-words">
                  {latestResult ? formatResult(latestResult.value) : '尚未执行请求。'}
                </pre>
              </CardContent>
            </Card>

            <Card>
              <CardHeader>
                <CardTitle>Data Scope</CardTitle>
                <CardDescription>对应旧控制台 Data 分组中的核心能力。</CardDescription>
              </CardHeader>
              <CardContent className="space-y-3 text-sm text-muted-foreground">
                <div className="flex items-start gap-3 rounded-2xl border border-border/70 bg-muted/20 p-3">
                  <FolderTree className="mt-0.5 size-4 text-foreground" />
                  <p>FileSystem 仅保留基础目录浏览和内容读取,不复刻旧版树形状态与结果面板联动。</p>
                </div>
                <div className="flex items-start gap-3 rounded-2xl border border-border/70 bg-muted/20 p-3">
                  <Search className="mt-0.5 size-4 text-foreground" />
                  <p>Find 结果会按动态列展示,避免绑定旧 BFF 的特定结构。</p>
                </div>
                <div className="flex items-start gap-3 rounded-2xl border border-border/70 bg-muted/20 p-3">
                  <Upload className="mt-0.5 size-4 text-foreground" />
                  <p>Add Resource  Add Memory 都直接走真实后端路径与真实参数模型。</p>
                </div>
                <div className="flex items-start gap-3 rounded-2xl border border-border/70 bg-muted/20 p-3">
                  <FileText className="mt-0.5 size-4 text-foreground" />
                  <p>所有加载与提交状态均由 TanStack Query 暴露,页面只负责渲染。</p>
                </div>
              </CardContent>
            </Card>
          </div>
        </div>
    </LegacyPageShell>
  )
}

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

PR Code Suggestions ✨

No code suggestions found for the PR.

ifeichuan and others added 8 commits April 7, 2026 23:35
This commit introduces a native `tags` parameter across the entire stack (Core, API, SDK, CLI) to
easily tag resources and filter them during semantic search.

Changes include:
   - **Core & Storage**:
    - Write `tags` to the resource root's `.meta.json` during `add_resource`.
    - Read tags from `.meta.json` during async semantic processing and hoist them to the VectorDB context for indexing.
    - Enrich directory stats/entries in `VikingFS` (`ls`, `stat`) with `tags`.
   - **API & Service**: Add `tags` field to resource creation and search routes.
   - **Python SDK**:
    - Add `tags` parameter to `add_resource`.
    - Add `tags` shortcut parameter to `find` and `search` methods, which automatically constructs the underlying `contains` metadata filters.
   - **Rust CLI**: Add `--tags` flag to `ov add-resource`, `ov find`, and `ov search` commands.
   - **Docs**: Update English and Chinese documentation for Resources, Retrieval, and Filesystem APIs to reflect the new `tags` parameters and structures.
   - **Tests**: Add unit tests for resource processor meta merging, VikingFS tag reading, and HTTP client tag filtering logic.
feat: 三段式布局 + 功能页拆分
- Add i18next with browser language detection (zh-CN / en)
- Add language switcher dropdown in header bar
- Add dark/light theme toggle with animated Sun/Moon icons
- Wire up next-themes ThemeProvider with class-based dark mode
- Replace shadcn dark theme with default Zinc (neutral gray)
- Connect sidebar labels to i18n translation keys
- Fix dropdown menu forced dark styling
Jye10032 and others added 30 commits April 13, 2026 12:29
`openviking-server`'s /bot/v1/chat/stream proxy handler used
`response.aiter_lines()` to relay SSE from the vikingbot gateway, which
introduces two layers of hidden buffering:

- Line buffering: aiter_lines() waits for a complete line ending in \n
  before yielding. The `if line:` filter also drops blank lines — which in
  SSE are the `\n\n` event separator, so the framing is lost. Downstream
  parsers that split on `\n\n` (including browser EventSource and the
  bot-test harness) never find a boundary and events pile up in the buffer
  until the stream closes, appearing as "empty response until completion".
- Body-level buffering: httpx accumulates bytes before splitting into
  lines. For ~40-byte SSE deltas this batches events into bursts, also
  hurting real-time first-byte latency.

Switch to response.aiter_bytes() and pass raw bytes through untouched.
This preserves the upstream's \n\n event framing exactly and avoids any
framing-aware buffering; downstream parsers receive chunks the moment
they arrive from the gateway.

Error-path yields are updated to emit bytes for signature consistency
with the new AsyncGenerator[bytes, None] return type.
response.text on a streaming client fails with ResponseNotRead once the
client context is exited (the connection is already closed by the time
the except block runs). str(e) contains the status code and URL, which
is sufficient for the error SSE payload.

Pre-existing issue in the same code path, caught by Gemini review on
the parent PR.
- Chat interface with assistant-ui runtime (SSE streaming, message history)
- Session title system: localStorage + AI-generated titles via sendChat
- Session list with search/filter (Cmd+K), new session (Cmd+N)
- Tool call collapsible cards with input/output display
- Reasoning expandable blocks with running/complete states
- Iteration badge for multi-step agent responses
- User/Assistant avatars, active session highlight
- Loading skeleton, error states, empty state with keyboard hints
…hat components

## Chat UI Architecture
- Remove @assistant-ui/react and @assistant-ui/react-markdown dependencies
- Remove adapter layer: use-assistant-runtime, use-thread-list-adapter, convert-message
- Delete assistant-ui primitives: thread.tsx, thread-list.tsx
- Components now consume useChat() directly without runtime conversion

## New Components (src/components/chat/)
- message-parts.tsx: MarkdownContent (Streamdown), ReasoningBlock, ToolCallBlock
- composer.tsx: floating frosted-glass input with backdrop-blur, file attachment support
- message-list.tsx: message bubbles with frosted-glass backgrounds (bg-background/70)
- thread.tsx: main chat area with PixelBlast background and auto-scroll

## Streaming Markdown
- Add streamdown, @streamdown/code, @streamdown/cjk for flicker-free streaming
- Incremental DOM updates instead of full re-parse on each token
- Shiki syntax highlighting and CJK text support

## Session Navigation
- Move session list into app-shell sidebar as collapsible sub-menu (NavSessionsItem)
- Session switching via URL search params (?s=sessionId), supports browser back/forward
- Remove embedded 260px thread-list panel from sessions page

## Visual Design
- PixelBlast (Three.js) particle background at 40% opacity with edge fade
- Frosted-glass message bubbles and composer (backdrop-blur-xl)
- Message width increased to max-w-3xl
- All user-facing text localized to Chinese

## Other
- Add use-file-attachment hook for file upload in composer
- Add pixel-blast.tsx (react-bits) as UI component
- bot: minor changes to ov_file.py and ov_server.py
- PixelBlast: defer mount until browser idle via requestIdleCallback,
  avoids blocking chat UI interactivity with 538KB Three.js chunk
- Auto-scroll: throttle via requestAnimationFrame, cancel pending
  scroll before scheduling new one (was firing per-token)
- UserMessage/AssistantMessage: wrap with React.memo to skip
  re-renders when streaming state changes (only streaming message
  needs updates, history messages are stable)
- NavSessionsItem: select only `s` param from router state instead
  of entire search object, prevents re-render on unrelated route changes
- Add hover-reveal copy button to both user and assistant messages
  (copies text content to clipboard, shows checkmark on success)
- Cache session message history for 30s (staleTime) to avoid
  flash/refetch when switching between sessions quickly
## Message Cards
- Same-role consecutive messages use compact spacing (mb-1.5 vs mb-5)
- User bubble: solid primary bg, tighter rounded-br-sm corner
- Assistant bubble: subtle ring-1 ring-border/30 for edge definition
- Avatar hidden for consecutive same-role messages (spacer preserves alignment)

## Brand Avatar
- Replace generic BotIcon with gradient ring avatar (from-primary/20 to-primary/5)
- Ring-1 ring-primary/10 for subtle brand color presence

## Markdown Prose
- Refined heading sizes: h1=lg, h2=base, h3=sm with proper spacing
- Inline code: muted bg, rounded, no backtick pseudo-elements
- Blockquote: primary left border + muted bg + rounded-r-lg
- Links: primary color, no underline by default, underline on hover
- Tables: uppercase tracking-wider th headers
- Relaxed line-height for paragraphs and lists

## Typing Indicator
- 3-dot bounce animation when streaming starts before content arrives
- Shows only when no content/reasoning/toolCalls yet

## Timestamps
- Hover-reveal relative time on each message (刚刚/分钟前/小时前)
- 10px muted text, positioned beside copy button

## Iteration Badge
- Changed from English "Iteration N" to "第 N 轮"
- Pill style: rounded-full, primary/10 bg, primary text

## Empty State
- Product name "OpenViking" with gradient icon container
- Suggestion pills: 3 quick-start prompts as rounded buttons

## Tool/Reasoning Blocks
- Softer borders (border/30), lighter backgrounds (muted/20)
- Smaller label text (10px) with wider tracking
…ills

- User bubble: rounded-tr-sm (top-right near avatar), was wrongly br
- Remove quick-start suggestion pills from empty state
Replace generic BotIcon with ov-logo.png in chat message bubbles.
## PixelBlast background
- Cap frame rate at 30fps (was uncapped 60fps) via frame budget throttle
- Set pixelRatio to 1 (was 2x on Retina), reduces render pixels by 4x
- Combined: ~8x less GPU work for the background animation

## Remove backdrop-blur from chat elements
- Message bubbles: bg-background/70 backdrop-blur-xl → bg-background/95
- Composer: bg-background/50 backdrop-blur-xl → bg-background/95
- Title bar: bg-background/80 backdrop-blur-sm → bg-background/95
- Each backdrop-blur created a compositing layer that re-blurred the
  animated canvas every frame — this was the main source of jank

## Logo compression
- Resize ov-logo.png from 1000x1000 (large) to 56x56 (2x render size)
- File size reduced to 2.2KB

## Scroll optimization
- Remove CSS scroll-smooth (conflicts with JS scrollIntoView smooth)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

7 participants