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
Original file line number Diff line number Diff line change
Expand Up @@ -314,25 +314,27 @@ private extension MobileAppModel {
)
timelineRows = [
MobileConversationRow(
id: userID, kind: "USER", text: "请检查移动端的消息、工具和文件交互。", thinking: nil,
images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, showRetry: false
id: userID, kind: "USER", text: "介绍本项目", thinking: nil,
images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false,
showRetry: false, error: nil
),
MobileConversationRow(
id: assistantID, kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [],
blocks: [
.thinking(id: "preview-thinking", text: "先对照 HarmonyOS 的消息顺序与工具状态,再核对 Android 的交互策略。", streaming: false),
.text(
id: "preview-text",
text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- Markdown 与代码块\n- 思考过程与子任务\n- 工具确认、提问和取消\n\n```swift\nlet parity = true\n```",
text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- [x] Markdown 与代码块\n- [x] 思考过程与子任务\n- [ ] 完成真机回归\n\n| 平台 | 状态 |\n| :--- | ---: |\n| HarmonyOS | 已对照 |\n| iOS | 已对齐 |\n\n```swift\nlet parity = true\n```",
streaming: false
),
.tools(id: "preview-tools", tools: [readOne, readTwo, approval, question]),
],
streaming: false, typing: false, pending: false, showRetry: false
streaming: false, typing: false, pending: false, showRetry: true,
error: "桌面端进程意外退出。"
),
]
messages = [
ChatMessage(id: UUID(), role: .user, text: "请检查移动端的消息、工具和文件交互。"),
ChatMessage(id: UUID(), role: .user, text: "介绍本项目"),
ChatMessage(id: UUID(), role: .assistant, text: "检查结果"),
]
}
Expand Down
312 changes: 271 additions & 41 deletions src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,23 @@ private struct ConversationRowView: View {
}

private var userRow: some View {
VStack(alignment: .trailing, spacing: 7) {
if !row.images.isEmpty { TimelineImageGrid(images: row.images) }
if !row.text.isEmpty {
Text(row.text)
.font(MobileDesignTypography.bodyLarge.font)
.foregroundStyle(OpenBitFunTheme.ink)
.lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing)
.textSelection(.enabled)
VStack(alignment: .trailing, spacing: 6) {
IntrinsicWidthCapLayout(maxWidth: MobileDesignGeometry.messageBubbleMaxWidth) {
VStack(alignment: .leading, spacing: 8) {
if !row.images.isEmpty { TimelineImageGrid(images: row.images) }
if !row.text.isEmpty {
Text(row.text)
.font(MobileDesignTypography.bodyLarge.font)
.foregroundStyle(OpenBitFunTheme.ink)
.lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing)
.padding(.vertical, MobileDesignTypography.bodyLarge.lineSpacing / 2)
.textSelection(.enabled)
}
}
.padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding)
.padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding)
.background(OpenBitFunTheme.soft)
.clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius))
}
if row.pending {
Text(model.localized("正在发送"))
Expand All @@ -117,12 +126,8 @@ private struct ConversationRowView: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding)
.padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding)
.frame(maxWidth: MobileDesignGeometry.messageBubbleMaxWidth, alignment: .trailing)
.background(OpenBitFunTheme.soft)
.clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius))
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.vertical, 2)
}

private var assistantRow: some View {
Expand All @@ -139,7 +144,9 @@ private struct ConversationRowView: View {
if !row.tools.isEmpty { ToolStatusList(tools: row.tools, model: model) }
}
if !row.images.isEmpty { TimelineImageGrid(images: row.images) }
if row.showRetry {
if let error = row.error, !error.isEmpty {
assistantFailure(error)
} else if row.showRetry {
Button { model.retryMessage(row.text) } label: {
Label(model.localized("重试"), systemImage: "arrow.clockwise")
.font(MobileDesignTypography.labelSmall.font)
Expand All @@ -150,6 +157,61 @@ private struct ConversationRowView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}

private func assistantFailure(_ error: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(model.localized("本次回复失败"))
.font(MobileDesignTypography.labelSmall.font.weight(.medium))
.foregroundStyle(OpenBitFunTheme.statusDanger)
Text(error)
.font(MobileDesignTypography.bodySmall.font)
.foregroundStyle(OpenBitFunTheme.ink)
.lineSpacing(MobileDesignTypography.bodySmall.lineSpacing)
.textSelection(.enabled)
if row.showRetry {
Button(model.localized("重试")) { model.retryMessage(row.text) }
.font(MobileDesignTypography.bodySmall.font.weight(.medium))
.foregroundStyle(MobileDesignColors.fileLink)
.buttonStyle(.plain)
}
}
.padding(.leading, 12)
.overlay(alignment: .leading) {
Rectangle().fill(OpenBitFunTheme.statusDanger).frame(width: 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

private struct IntrinsicWidthCapLayout: Layout {
let maxWidth: CGFloat

func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache _: inout ()
) -> CGSize {
guard let subview = subviews.first else { return .zero }
let availableWidth = min(proposal.width ?? maxWidth, maxWidth)
let size = subview.sizeThatFits(
ProposedViewSize(width: availableWidth, height: proposal.height)
)
return CGSize(width: min(size.width, availableWidth), height: size.height)
}

func placeSubviews(
in bounds: CGRect,
proposal _: ProposedViewSize,
subviews: Subviews,
cache _: inout ()
) {
guard let subview = subviews.first else { return }
subview.place(
at: bounds.origin,
anchor: .topLeading,
proposal: ProposedViewSize(width: bounds.width, height: bounds.height)
)
}
}

private struct EmptyConversationRow: View {
Expand Down Expand Up @@ -318,22 +380,31 @@ private struct MarkdownBlockView: View {
VStack(alignment: .leading, spacing: 5) {
ForEach(block.items, id: \.id) { item in
HStack(alignment: .firstTextBaseline, spacing: 7) {
Text(item.marker).foregroundStyle(OpenBitFunTheme.muted)
.frame(width: 20, alignment: .trailing)
Text(inlineString(item.inlines)).foregroundStyle(OpenBitFunTheme.ink)
if let checked = taskListState(item.text) {
ZStack {
RoundedRectangle(cornerRadius: 4)
.stroke(OpenBitFunTheme.line, lineWidth: 1)
if checked {
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(OpenBitFunTheme.muted)
}
}
.frame(width: 16, height: 16)
.padding(.horizontal, 2)
} else {
Text(item.marker).foregroundStyle(OpenBitFunTheme.muted)
.frame(width: 20, alignment: .trailing)
}
Text(listItemInlineString(item)).foregroundStyle(OpenBitFunTheme.ink)
.lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing)
.textSelection(.enabled)
}
.font(MobileDesignTypography.bodyLarge.font)
}
}
case "code": CodeBlock(language: block.language, code: block.text)
case "table":
ScrollView(.horizontal, showsIndicators: false) {
Text(block.text).font(.system(size: 12.5, design: .monospaced))
.foregroundStyle(OpenBitFunTheme.ink).padding(12).textSelection(.enabled)
}
.background(OpenBitFunTheme.soft).clipShape(RoundedRectangle(cornerRadius: 12))
case "table": MarkdownTableView(source: block.text)
case "divider": Rectangle().fill(OpenBitFunTheme.line).frame(height: 1).padding(.vertical, 3)
default:
Text(inlineString(block.inlines))
Expand All @@ -351,25 +422,184 @@ private struct MarkdownBlockView: View {
}

private func inlineString(_ inlines: [MarkdownInline]) -> AttributedString {
var result = AttributedString()
for inline in inlines {
var part = AttributedString(inline.text)
switch inline.type {
case "strong": part.font = .system(size: 14, weight: .semibold)
case "emphasis": part.font = .system(size: 14).italic()
case "code":
part.font = .system(size: 13, design: .monospaced)
part.backgroundColor = OpenBitFunTheme.soft
case "link":
part.foregroundColor = MobileDesignColors.fileLink
part.underlineStyle = .single
part.link = URL(string: inline.url)
default: break
}
result.append(part)
}
return result.characters.isEmpty ? AttributedString(block.text) : result
markdownInlineString(inlines, fallback: block.text)
}

private func taskListState(_ text: String) -> Bool? {
let prefix = text.prefix(3).lowercased()
if prefix == "[x]" { return true }
if prefix == "[ ]" { return false }
return nil
}

private func listItemInlineString(_ item: MarkdownListItem) -> AttributedString {
guard taskListState(item.text) != nil else {
return markdownInlineString(item.inlines, fallback: item.text)
}
let text = String(item.text.dropFirst(3)).trimmingCharacters(in: .whitespaces)
return markdownInlineString(
MarkdownParser.shared.parseInlineText(value: text),
fallback: text
)
}
}

private struct MarkdownTableView: View {
let source: String

private var table: MarkdownTableData { MarkdownTableData(source: source) }

var body: some View {
ViewThatFits(in: .horizontal) {
tableGrid(flexibleColumns: true)
ScrollView(.horizontal, showsIndicators: false) {
tableGrid(flexibleColumns: false)
}
}
.background(OpenBitFunTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(OpenBitFunTheme.line, lineWidth: 1))
}

private func tableGrid(flexibleColumns: Bool) -> some View {
Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) {
ForEach(Array(table.rows.enumerated()), id: \.offset) { rowIndex, row in
GridRow(alignment: .top) {
ForEach(Array(row.cells.enumerated()), id: \.offset) { columnIndex, cell in
Text(markdownInlineString(
MarkdownParser.shared.parseInlineText(value: cell.text),
fallback: cell.text
))
.font(MobileDesignTypography.bodySmall.font)
.foregroundStyle(OpenBitFunTheme.ink)
.multilineTextAlignment(cell.textAlignment)
.textSelection(.enabled)
.frame(
minWidth: 132,
maxWidth: flexibleColumns ? .infinity : 180,
minHeight: 42,
alignment: cell.frameAlignment
)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(rowIndex == 0 || rowIndex.isMultiple(of: 2)
? OpenBitFunTheme.soft
: OpenBitFunTheme.card)
.overlay(alignment: .trailing) {
if columnIndex < row.cells.count - 1 {
Rectangle().fill(OpenBitFunTheme.line).frame(width: 1)
}
}
}
}
.overlay(alignment: .bottom) {
if rowIndex < table.rows.count - 1 {
Rectangle().fill(OpenBitFunTheme.line).frame(height: 1)
}
}
}
}
.frame(maxWidth: flexibleColumns ? .infinity : nil)
}
}

private struct MarkdownTableData {
struct Row { let cells: [Cell] }
struct Cell {
let text: String
let alignment: Alignment

var textAlignment: TextAlignment {
if alignment == .center { return .center }
if alignment == .trailing { return .trailing }
return .leading
}

var frameAlignment: Alignment { alignment }
}

let rows: [Row]

init(source: String) {
let lines = source.split(separator: "\n", omittingEmptySubsequences: true).map(String.init)
guard lines.count >= 2 else {
rows = [Row(cells: [Cell(text: source, alignment: .leading)])]
return
}
let header = Self.splitRow(lines[0])
let separators = Self.splitRow(lines[1])
let alignments = separators.map(Self.alignment)
let values = [header] + lines.dropFirst(2).map(Self.splitRow)
let columnCount = max(header.count, alignments.count)
rows = values.map { row in
Row(cells: (0..<columnCount).map { index in
Cell(
text: index < row.count ? row[index] : "",
alignment: index < alignments.count ? alignments[index] : .leading
)
})
}
}

private static func splitRow(_ line: String) -> [String] {
var source = line.trimmingCharacters(in: .whitespaces)
if source.first == "|" { source.removeFirst() }
if source.last == "|" { source.removeLast() }
var cells: [String] = []
var current = ""
var inCode = false
var escaped = false
for character in source {
if escaped {
current.append(character)
escaped = false
} else if character == "\\" {
escaped = true
} else if character == "`" {
inCode.toggle()
current.append(character)
} else if character == "|" && !inCode {
cells.append(current.trimmingCharacters(in: .whitespaces))
current = ""
} else {
current.append(character)
}
}
if escaped { current.append("\\") }
cells.append(current.trimmingCharacters(in: .whitespaces))
return cells
}

private static func alignment(_ separator: String) -> Alignment {
let value = separator.trimmingCharacters(in: .whitespaces)
if value.hasPrefix(":") && value.hasSuffix(":") { return .center }
if value.hasSuffix(":") { return .trailing }
return .leading
}
}

private func markdownInlineString(
_ inlines: [MarkdownInline],
fallback: String
) -> AttributedString {
var result = AttributedString()
for inline in inlines {
var part = AttributedString(inline.text)
switch inline.type {
case "strong": part.font = .system(size: 14, weight: .semibold)
case "emphasis": part.font = .system(size: 14).italic()
case "code":
part.font = .system(size: 13, design: .monospaced)
part.backgroundColor = OpenBitFunTheme.soft
case "link":
part.foregroundColor = MobileDesignColors.fileLink
part.underlineStyle = .single
part.link = URL(string: inline.url)
default: break
}
result.append(part)
}
return result.characters.isEmpty ? AttributedString(fallback) : result
}

private struct CodeBlock: View {
Expand Down
Loading