Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Admin 默认访问地址为 `http://localhost:3000/admin/`。在另一个终端
| `VITE_API_TARGET` | 未设置 | 未设置 | 覆盖开发代理的后端地址 |

当前未设置 `VITE_API_TARGET` 时,使用 `vite.config.ts` 中的 `defaultBackendTarget`,
值为 `http://47.120.55.164:38080`。连接本地后端时,在 `env/.env.local` 中配置:
值为 `https://mkv3-ee.fit2cloud.cn/`。连接本地后端时,在 `env/.env.local` 中配置:

```dotenv
VITE_API_TARGET=http://127.0.0.1:8080
Expand Down
15 changes: 12 additions & 3 deletions ui/src/api/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@
`core/request.ts` 提供独立的 Axios 客户端、JSON 响应解包和 `postStream` 流式请求,
不复用 Admin 请求客户端。JSON 与流式请求通过 `@/stores/chat` 的 `useStore()` 获取当前认证场景
(门户或单应用)的 token 和语言。
401 响应(`/auth/` 认证接口除外)不弹出通用提示,调用 `@/router/chat` 的 `reauthenticate()` 重新认证。
非认证接口返回 401 时不弹出通用提示,调用 `@/router/chat` 的 `reauthenticate()` 重新认证。
流式请求返回原始 `Response`,由对话面板解析,不在请求层维护消息或 loading。

## 对话

`conversation.ts` 维护正式对话的打开、发送、取消、续传、历史分页、记录分页、删除、修改、
语音识别接口,默认导出完整 API 对象。调试对话接口位于
`admin/workspace/conversation.ts`,面板模式选择位于 `conversation-panel/common/get-api.ts`。

Chat 业务接口统一调用后端 v3 路由(`/chat/api/v3/...`);文件上传 `/oss/file` 由 oss 模块提供,不在 v3 下。
正式对话接口使用后端 `/v3/...` 路由,由请求客户端追加 Chat API 部署前缀;
公开分享读取使用 `/share/<shareId>`,文件上传使用 `/oss/file`,部分认证使用 `/auth/...`,不统一追加 v3。
通用错误提示使用响应的 `message`;响应为 HTML 时按 HTTP 状态显示通用文案。

接口命名、类型和请求约定统一遵循 `../API_README.md`。
接口命名、类型和请求约定统一遵循 [API_README.md](../API_README.md)。

## 文件上传

`file.ts` 提供通用 `postUploadFile`,通过本应用的 `core/request.ts` 中 `postUpload` 支持
可选进度回调及取消,统一返回 `{ request, abort }`。`request` 解包得到文件地址;
取消仍拒绝 Promise,但不弹出通用错误提示。loading 由调用方管理。

## 认证与用户

`auth.ts` 维护门户与单应用的认证:`getApplicationAuthProfile(accessToken)` 请求 `/v3/profile`,
`getPortalAuthProfile()` 请求 `/v3/portal/profile`,两者返回结构相同的 `AuthProfile`,差异字段在 `meta`。
主题配置位于响应顶层 `custom_theme`,不在 `meta` 内;Chat Auth Store 获取或复用该配置后,
Expand All @@ -36,3 +43,5 @@ Chat 业务接口统一调用后端 v3 路由(`/chat/api/v3/...`);文件
`getDingTalkOauthCallback` 使用 `/auth/dingtalk/oauth2`,传入 `code` 和可选的 `accessToken`。
两者返回 `ChatLoginResponse`,由认证 Store 按场景保存 token;接口需部署端提供对应认证能力。
`/auth/` 与 `/v3/auth/` 下的认证请求返回 401 时直接提示错误,不触发自动重新认证。

公开分享与回答反馈的路径、载荷和只读边界统一见主 API 文档的[公开对话分享](../API_README.md#公开对话分享)与[对话回答反馈](../API_README.md#对话回答反馈),页面交互见 View 文档。
2 changes: 2 additions & 0 deletions ui/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ declare module 'vue' {
TableFilterSingle: typeof import('./components/global/mk-table/mk-table-filter/TableFilterSingle.vue')['default']
ToolIcon: typeof import('./components/global/mk-icon/ToolIcon.vue')['default']
TriggerIcon: typeof import('./components/global/mk-icon/TriggerIcon.vue')['default']
VoiceWaveIcon: typeof import('./components/global/mk-icon/VoiceWaveIcon.vue')['default']
}
}

Expand Down Expand Up @@ -111,4 +112,5 @@ declare global {
const TableFilterSingle: typeof import('./components/global/mk-table/mk-table-filter/TableFilterSingle.vue')['default']
const ToolIcon: typeof import('./components/global/mk-icon/ToolIcon.vue')['default']
const TriggerIcon: typeof import('./components/global/mk-icon/TriggerIcon.vue')['default']
const VoiceWaveIcon: typeof import('./components/global/mk-icon/VoiceWaveIcon.vue')['default']
}
29 changes: 18 additions & 11 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ Symbol ID 变化时同步引用。
`--el-loading-spinner-size`。数字按 px 处理,字符串需携带 CSS 单位,例如
`<LoadingIcon :size="24" />` 或 `<LoadingIcon size="2rem" />`。

`VoiceWaveIcon` 位于 `global/mk-icon/VoiceWaveIcon.vue`,模板中自动注册,使用纯 CSS 持续循环播放
五根音柱的缩放动画,不依赖录音或播放状态。`size` 默认 `24`,数字按 px 处理,字符串需携带
CSS 单位;`color` 默认继承文字颜色,也可通过 `text-primary` 设置主题色,例如
`<VoiceWaveIcon :size="24" class="text-primary" />`。系统启用减少动态效果时显示静态音柱。

### MkInfiniteScroll

分页列表的滚动触底加载组件,使用 `IntersectionObserver` 监听组件所在滚动区域的底部,不依赖
Expand Down Expand Up @@ -975,6 +980,19 @@ Workspace API 内部使用 `getWorkspaceId()`,System 共享 API 不接收工
聊天列表订阅流式进度后调用 `followBottom()`;只读日志无需订阅。
`message-list` 和 `log-message-list` 使用该容器,保留各自的消息正文与底部信息;聊天列表
自行展示流式圆点,开场白放入 `prepend` 插槽,日志空状态放入 `empty` 插槽。日志列表不注入发送能力。
消息 Store 的 `loadMore()` 在新增历史记录时返回 `true`,空页、失败或未执行时返回 `false`,
直接传给滚动容器。新会话及重置时 `hasMore` 为 `false`;已有会话主动加载首页,成功后再更新
`hasMore`。首页失败后通过重新加载首页重试,不从下一页继续。
Chat、Portal 和 Display 面板直接放置消息列表,并以当前会话 ID 为 `key`,不再包裹额外滚动层。

### Question(对话模块)

`conversation-panel/components/content/items/question/index.vue` 负责提问展示、附件和操作入口。
同目录 `QuestionEditor.vue` 接收 `content`、`disabled`,维护本次编辑草稿,通过 `cancel` 和
`send(question)` 通知入口组件;进入时挂载并聚焦,退出时卸载。入口组件负责发送能力与附件传递。

### 分享选择(对话模块)

正式对话的 `message-list` 根据会话 Store 的 `isShared` 展示分享选择态,同轮提问与回答同步选择,
内容只读且选中时显示背景;无记录 ID 的问答不可选。列表底部提供当前已加载轮次的全选、已选数量、
取消及创建链接,创建时按消息顺序提交记录 ID;成功退出分享,失败保留选择。分享期间主面板隐藏输入框,
Expand All @@ -991,17 +1009,6 @@ Chat、Portal 视图创建并 provide `SHARE_LINK_KEY`,组件通过 `useShareL
`ShareMessageRow` 通过插槽承载消息,`ShareFooter` 展示全选、数量、取消与创建链接并在卸载时退出分享态;
两者只绑定 Store 状态与方法,不维护逻辑。`message-list/index.vue` 只组合组件并传入 Store 提供的记录 ID。

消息 Store 的 `loadMore()` 在新增历史记录时返回 `true`,空页、失败或未执行时返回 `false`,
直接传给滚动容器。新会话及重置时 `hasMore` 为 `false`;已有会话主动加载首页,成功后再更新
`hasMore`。首页失败后通过重新加载首页重试,不从下一页继续。
Chat、Portal 和 Display 面板直接放置消息列表,并以当前会话 ID 为 `key`,不再包裹额外滚动层。

### Question(对话模块)

`conversation-panel/components/content/items/question/index.vue` 负责提问展示、附件和操作入口。
同目录 `QuestionEditor.vue` 接收 `content`、`disabled`,维护本次编辑草稿,通过 `cancel` 和
`send(question)` 通知入口组件;进入时挂载并聚焦,退出时卸载。入口组件负责发送能力与附件传递。

### SharePanel(对话模块)

`conversation-panel/main/share-panel/index.vue` 是公开分享页的主面板,展示标题栏并组合
Expand Down
62 changes: 62 additions & 0 deletions ui/src/components/global/mk-icon/VoiceWaveIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'

defineOptions({ name: 'VoiceWaveIcon' })

const props = withDefaults(defineProps<{ size?: number | string; color?: string }>(), { size: 24 })

const waveSize = computed(() => (typeof props.size === 'number' ? `${props.size}px` : props.size))
</script>

<template>
<span class="voice-wave-icon shrink-0" :style="{ width: waveSize, height: waveSize, color }" aria-hidden="true">
<template v-for="barIndex in 5" :key="barIndex">
<span class="voice-wave-icon__bar" />
</template>
</span>
</template>

<style scoped lang="scss">
.voice-wave-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10%;
vertical-align: middle;

&__bar {
width: 10%;
height: 40%;
flex-shrink: 0;
border-radius: 999px;
background: currentColor;
animation: voice-wave 1s ease-in-out infinite;

&:nth-child(2),
&:nth-child(4) {
height: 70%;
animation-delay: -0.2s;
}

&:nth-child(3) {
height: 100%;
animation-delay: -0.4s;
}

@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}

@keyframes voice-wave {
0%,
100% {
transform: scaleY(0.45);
}

50% {
transform: scaleY(1);
}
}
</style>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a review of the provided Vue component code, highlighting irregularities, potential issues, and optimization suggestions.

1. Critical Irregularity: Missing Keyframes Name Consistency

While not technically an error if consistent, it is a best practice to ensure the animation name in animation matches exactly with @keyframes. Here they match (voice-wave), so this is fine, but note that animation-delay without negative values on some bars may look disjointed. Currently, bars 1 and 5 have no delay, bar 2/4 have -0.2s, and bar 3 has -0.4s. This creates a wave effect starting from the center outward, which is visually intuitive. No change needed here unless specific timing is desired for outer bars.

2. Potential Issue: Percentage-Based Heights + scaleY Animation Conflict

  • Issue: The .voice-wave-icon__bar elements have fixed percentage heights (e.g., height: 40%, 70%, 100%) relative to the parent container. The @keyframes voice-wave animates transform: scaleY().
  • Visual Result: When scaleY(0.45) is applied to a bar already at 40% height, it becomes ~18% of the parent's height. When scaleY(1) is applied, it returns to its original CSS-defined height. This means the bars do not actually reach equal visual peaks unless their base heights are equal. The "wave" will look uneven because Bar 1 starts small and grows, Bar 3 starts tall and shrinks slightly then grows to full.
  • Suggestion: If you want a true wave where all bars peak at the same maximum height, consider using transform-origin: center bottom; and animating from scaleY(min) to scaleY(max) or set all bars to height: 100% and use scaleY variations as the base state. However, the current approach is common for simple "audio visualizer" icons. Just be aware the visual "peak" height varies per bar due to differing base sizes.

3. Accessibility & Semantics

  • Good: aria-hidden="true" is correctly used since this is a decorative icon.
  • Suggestion: Consider adding role="img" if this icon conveys meaningful status (e.g., "voice active"), though usually, such status is conveyed by surrounding text or other ARIA states. For now, aria-hidden is appropriate.

4. Optimization: Template Loop Syntax

  • Current: <template v-for="barIndex in 5" :key="barIndex">
  • Improvement: Using :key="barIndex" is correct. However, you can simplify if you don't need the index variable explicitly beyond keying. It’s fine as-is.
  • Alternative: You could define a computed array for bars if you later need more dynamic control, but for static 5 bars, this is optimal.

5. Code Style & Best Practices

A. Prop Validation Enhancements

const props = withDefaults(defineProps<{ size?: number | string; color?: string }>(), { 
  size: 24,
  color: // Consider if a default color like 'currentColor' should be added here
})
  • Suggestion: color prop currently defaults to undefined. The template uses :style="{ ..., color }". If undefined, it might cause unintended inheritances or invalid CSS values depending on the browser. It’s safer to either:
    1. Remove the color prop if inheriting currentColor from parent is intended.
    2. Provide a default color: { size: 24, color: 'currentColor' }.

B. SCSS: Reduced Motion Fallback

  • Current:
    @media (prefers-reduced-motion: reduce) {
      animation: none;
    }
  • Improvement: When animation is disabled, the bars remain at their initial CSS height values (40%, 70%, 100%). This is good—it preserves the visual shape without motion. However, ensure the transform property isn’t stuck in a mid-animation state (it won’t be, since no animation is applied). This is correct.

C. CSS Performance

  • current-color usage is efficient.
  • border-radius: 999px ensures pill shapes regardless of width/height changes—this is a robust trick.

D. Gap Property

  • gap: 10%; inside display: inline-flex; with percentage gaps can sometimes behave unexpectedly depending on the flex context and container sizing. Since the parent is an inline element with defined width/height, this should work, but test across browsers for consistent spacing. Pixel-based gaps or fr units (if using grid) might be more predictable, but for simple icons, % is often acceptable.

✅ Suggested Revised Code (Minor Tweaks)

<script setup lang="ts">
import { computed } from 'vue'

defineOptions({ name: 'VoiceWaveIcon' })

// Added default for color to avoid undefined style injection
const props = withDefaults(defineProps<{ size?: number | string; color?: string }>(), { 
  size: 24,
  color: 'currentColor' 
})

const waveSize = computed(() => (typeof props.size === 'number' ? `${props.size}px` : props.size))
</script>

<template>
  <span 
    class="voice-wave-icon shrink-0" 
    :style="{ width: waveSize, height: waveSize, color }" 
    aria-hidden="true"
  >
    <span 
      v-for="i in 5" 
      :key="i" 
      class="voice-wave-icon__bar" 
    />
  </span>
</template>

<style scoped lang="scss">
.voice-wave-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10%; /* Verify cross-browser consistency */
  vertical-align: middle;

  &__bar {
    width: 10%;
    height: 40%;
    flex-shrink: 0;
    border-radius: 999px;
    background: currentColor;
    animation: voice-wave 1s ease-in-out infinite;

    &:nth-child(2),
    &:nth-child(4) {
      height: 70%;
      animation-delay: -0.2s;
    }

    &:nth-child(3) {
      height: 100%;
      animation-delay: -0.4s;
    }

    /* Ensure transform-origin is explicit for predictable scaling */
    transform-origin: center;
  }
}

/* Define keyframes outside conditional media queries if needed, or keep scoped as is */
@keyframes voice-wave {
  0%,
  100% {
    transform: scaleY(0.45);
  }

  50% {
    transform: scaleY(1);
  }
}
</style>

/* NOTE: Add reduced motion support properly in SCSS structure if nested media queries are problematic */
@media (prefers-reduced-motion: reduce) {
  .voice-wave-icon__bar {
    animation: none !important;
  }
}
</style>

Summary of Recommendations:

  1. Define default color: Prevents undefined being passed into inline styles.
  2. Add transform-origin: Explicitly setting center ensures scaling behaves predictably across browsers.
  3. Move @media (prefers-reduced-motion): While nesting it inside &__bar works in modern SCSS compilers supporting modern CSS specs, defining global utility classes or top-level media queries for accessibility rules is often clearer and safer for CSS extraction tools.
  4. Visual Check: Confirm the varying base heights + scaleY produces the desired aesthetic. If you want all bars to appear to "pulse" equally in height variation, consider making all bars height: 100% and varying only the scaleY start/end points in keyframes (which would require different keyframes or CSS custom properties per bar for complex delays/scales).

5 changes: 3 additions & 2 deletions ui/src/conversation-panel/components/message-footer/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { datetimeFormat } from '@/utils/time'
import type { ChatMessage, ChatRecordMeta } from '../../core/types'
import { useExecutionDetailStore } from '../../right-sidebar/execution-detail/index'

import { MESSAGE_LIST_KEY, useMessageListStore } from '../message-list/index'
import { MESSAGE_LIST_KEY } from '../message-list/index'
import { useShareLinkStoreOptional } from '../message-list/share/index'
import { useShowShare } from '../../view/index'

Expand Down Expand Up @@ -173,7 +173,8 @@ function openFeedback(status: '0' | '1') {
<span>
<MkTooltip :content="speaking ? '停止朗读' : '朗读内容'">
<el-button text :disabled="!speechSupported || !answerText" @click="toggleReading">
<MkIcon :name="speaking ? 'icon_pause-round_outlined' : 'icon_speaker_outlined'" />
<VoiceWaveIcon v-if="speaking" :size="16" class="text-primary" />
<MkIcon v-else name="icon_speaker_outlined" />
</el-button>
</MkTooltip>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { MsgError } from '@/utils/message'
/** 单次录音最长秒数,到时自动结束并转写。 */
export const VOICE_RECORD_MAX_SECONDS = 60
/** 录音波形柱数量。 */
const WAVE_BAR_COUNT = 60
const WAVE_BAR_COUNT = 80

// 关闭 recorder-core 的控制台日志
Recorder.CLog = () => {}
Expand Down Expand Up @@ -50,8 +50,22 @@ export function useVoiceRecorder(onRecorded: (audio: Blob) => void) {
type: 'mp3',
bitRate: 128,
sampleRate: 16000,
onProcess: (_buffers: unknown, powerLevel: number) => {
waveLevels.value = [...waveLevels.value.slice(1), powerLevel]
onProcess: (buffers: Int16Array[]) => {
// 分段计算最新音频的能量,整排随当前声音刷新,静音时收回点状基线。
const samples = buffers[buffers.length - 1]
waveLevels.value = Array.from({ length: WAVE_BAR_COUNT }, (_, index) => {
if (!samples?.length) return 0
const start = Math.floor((index * samples.length) / WAVE_BAR_COUNT)
const end = Math.floor(((index + 1) * samples.length) / WAVE_BAR_COUNT)
if (end === start) return 0
let energy = 0
for (let offset = start; offset < end; offset++) {
const amplitude = samples[offset]! / 32768
energy += amplitude * amplitude
}
const volume = Math.sqrt(energy / (end - start))
return Math.min(100, Math.max(0, volume - 0.005) * 500)
})
},
})
recorder = instance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my review of the code changes:

Issues & Potential Problems

1. onProcess signature change may break at runtime

The original code used powerLevel: number as the second parameter, which matches recorder-core's documented API ((buffers, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd)). The new code ignores powerLevel and uses buffers[buffers.length - 1].

  • Verify: Does the installed recorder-core version actually pass Int16Array[] here? Some versions pass raw ArrayBuffer/Float32Array[], and some only populate buffers for certain formats. If buffers are empty or of a different type, amplitude = samples[offset]! / 32768 will produce NaN.
  • Type safety concern: The non-null assertion samples[offset]! is unsafe; if samples is a typed array of a non-number type (or undefined), you get NaN, which propagates into waveLevels silently.

2. Division by 32768 assumes Int16 PCM

If buffers are Float32Array (some recorder-core builds emit float PCM), dividing by 32768 crushes values to ~0. Add a type check or normalize defensively.

3. Magic numbers

0.005 (noise gate) and 500 (scaling factor) are unexplained constants. Extract them as named constants (e.g., SILENCE_FLOOR, AMPLITUDE_SCALE) with comments — otherwise future maintainers won't know why 500 was chosen, or that output is clamped to 0–100.

4. Per-frame full recomputation cost

Rebuilding an 80-element array via Array.from on every onProcess callback (typically every ~50ms) plus an inner loop over samples.length / 80 bins per bar is fine at this size, but:

  • Prefer reusing a preallocated array (waveLevels.value[i] = ...) instead of Array.from each tick to reduce GC churn.
  • Cache samples.length / WAVE_BAR_COUNT outside the loop.

5. Behavioral change: no more scrolling history

Old code shifted left and appended one level (a rolling waveform over time). New code replaces the whole row each frame with a spatial RMS breakdown of the latest chunk. This is a UX semantic change, not just a tuning change — confirm this is intended, since the bar count increase (60→80) suggests it was treated as cosmetic.

6. Math.min(100, Math.max(0, volume - 0.005) * 500) ordering

The clamp wraps correctly, but note: subtracting 0.005 before multiplying means values below ~0.005 become negative and are clamped to 0 — that's the intended silence gate. Fine, just make the intent explicit.

7. end === start guard returns 0

Good defensive guard for when samples.length < WAVE_BAR_COUNT. However, if samples.length < 80, most bars will be 0 each frame — consider falling back to powerLevel (still available as arg 2) or interpolating when the buffer is short.

8. Unused powerLevel

Since you removed the param from the signature, the still-passed value is discarded. You could keep it as a fallback: onProcess: (buffers, powerLevel) => { ... } and use it if samples is missing.

9. Missing cleanup

No visible teardown of onProcess on unmount/stop (may exist elsewhere in file, but worth confirming) — the closure retaining waveLevels and WAVE_BAR_COUNT is harmless, but ensure the recorder instance is released.

Suggested Refinement

const SILENCE_FLOOR = 0.005
const AMPLITUDE_SCALE = 500
const MAX_LEVEL = 100
const PCM_INT16_MAX = 32768

onProcess: (buffers: Int16Array[], powerLevel: number) => {
  const samples = buffers[buffers.length - 1]
  if (!samples?.length) {
    // fallback to legacy behavior
    waveLevels.value = [...waveLevels.value.slice(1), powerLevel]
    return
  }
  const binSize = samples.length / WAVE_BAR_COUNT
  const next = waveLevels.value  // reuse array
  for (let i = 0; i < WAVE_BAR_COUNT; i++) {
    const start = Math.floor(i * binSize)
    const end = Math.floor((i + 1) * binSize)
    if (end <= start) { next[i] = 0; continue }
    let energy = 0
    for (let o = start; o < end; o++) {
      const a = (samples[o] ?? 0) / PCM_INT16_MAX
      energy += a * a
    }
    const rms = Math.sqrt(energy / (end - start))
    next[i] = Math.min(MAX_LEVEL, Math.max(0, rms - SILENCE_FLOOR) * AMPLITUDE_SCALE)
  }
  waveLevels.value = [...next]  // trigger reactivity once
}

Summary

The logic is reasonable, but the main risk is the changed onProcess signature — verify against your recorder-core version, guard against NaN, extract magic numbers, and confirm the UX change from rolling-time waveform to spatial-latest-chunk visualization was intentional.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ const emit = defineEmits<{
stop: []
}>()

/** 波形柱最大高度(px),与波形容器 h-8 一致。 */
const WAVE_MAX_HEIGHT = 32
/** 波形在固定高度容器内围绕中线起伏。 */
const WAVE_MAX_HEIGHT = 24
const WAVE_MIN_HEIGHT = 2

const waveHeights = computed(() =>
props.waveLevels.map((level) => WAVE_MIN_HEIGHT + (Math.max(0, Math.min(100, level)) / 100) * (WAVE_MAX_HEIGHT - WAVE_MIN_HEIGHT)),
)

const recordTime = computed(() => {
const minutes = String(Math.floor(props.seconds / 60)).padStart(2, '0')
Expand All @@ -24,14 +29,13 @@ const recordTime = computed(() => {
</script>

<template>
<div class="mk-input-box mk-conversation-message-input border-primary/60! bg-primary/10 w-full shadow-lg">
<div class="flex h-8 items-center justify-center gap-0.5">
<span
v-for="(level, index) in waveLevels"
:key="index"
class="bg-primary w-0.5 rounded-full transition-[height] duration-100"
:style="{ height: `${Math.max(2, (level / 100) * WAVE_MAX_HEIGHT)}px` }"
/>
<div class="mk-input-box mk-conversation-message-input border-primary/60! bg-primary/10! w-full shadow-lg">
<div class="flex-align-center h-8 justify-center">
<div class="flex-align-center w-full max-w-90 justify-between px-4">
<template v-for="(height, index) in waveHeights" :key="index">
<span class="bg-primary w-[2px] shrink-0 rounded-full transition-[height] duration-100 ease-out" :style="{ height: `${height}px` }" />
</template>
</div>
</div>
<div class="mt-3 flex items-center justify-end">
<span class="text-N400 mr-4">{{ recordTime }}</span>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my review of the code changes:

Potential Issues

1. Behavior change: waveform distribution
The original used gap-0.5 (fixed spacing, ~2px) between bars. The new version uses justify-between across a w-full max-w-90 container. This means:

  • Bar spacing now depends on container width and bar count — for few levels, bars spread far apart; for many, they clump.
  • If max-w-90 (360px) isn't wide enough for all bars at 2px + spacing, bars may overlap or shrink unpredictably. Consider shrink-0 helps but overflow is still possible.

2. Height baseline inconsistency

  • Original minimum height was 2px via Math.max(2, ...).
  • New code uses WAVE_MIN_HEIGHT = 2 as an additive base: 2 + (level/100) * (24-2). So a level of 0 now gives 2px, and 100 gives 24px — comparable, but note WAVE_MAX_HEIGHT dropped from 32 to 24 while container is h-8 (32px). Confirm this is intentional; there's now 8px of unused vertical space.

3. Level clamping added — good
Math.max(0, Math.min(100, level)) now guards against out-of-range values that the original didn't handle. This is an improvement, but ensure upstream callers don't rely on values >100 producing taller bars.

4. computed over props.waveLevels
Since waveHeights depends on props.waveLevels, verify waveLevels is actually reactive (declared in props). Also confirm its type is number[] — TypeScript would benefit from explicit typing here.

Style / Optimization Suggestions

5. Power-of-two spacing
max-w-90 followed by px-4 gives effectively 352px inner width. If bar count varies (e.g., 40–60 for audio), justify-between yields sub-pixel gaps → inconsistent rendering. Prefer fixed gap-[Xpx] and let width be content-driven (w-fit mx-auto) unless you specifically want edge-to-edge fill.

6. Redundant <template> wrapper

<template v-for="(height, index) in waveHeights" :key="index">

The <template> is unnecessary since only one child exists — just put v-for and :key directly on the <span>. Cleaner and avoids a wrapper node.

7. Magic numbers in class string
w-[2px] duplicates nothing but consider keeping bar width in sync with WAVE_* constants via CSS custom property if it becomes tunable.

8. Transition on height only
transition-[height] duration-100 ease-out — good. But if the number of bars changes reactively, keying by index causes Vue to reuse DOM nodes and animate wrong elements. Use a stable key if levels map to identifiable positions; otherwise this is acceptable for a waveform visual.

9. bg-primary/10!
Added ! (important) to match the border-primary/60! pattern — consistent, fine. Just verify no utility conflicts.

10. Accessibility
The waveform spans are purely decorative. Consider adding aria-hidden="true" to the container so screen readers skip them.

Summary

Functionally sound, main concerns are layout robustness (variable bar count vs. justify-between in fixed-width box) and confirming the intentional reduction from 32→24px max height. Minor cleanups: drop the redundant <template>, add aria-hidden, and consider fixed gap over justify-between.

Expand Down
Loading
Loading