Skip to content
Clone

feat(ai): integrate Markdown rendering for AI-generated content in summary and review sections

ZhenYi committed
parent f8f721d commit 60c864c Browse files

22 files changed

+764 −210

File tree 22
Modified admin/openapi.json View admin/openapi.json
@@ -2166,7 +2166,7 @@
21662166 "tags": [
21672167 "admin-queues"
21682168 ],
2169 "description": "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks)。",
2169 "description": "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks/ai)。AI 队列会按持久化载荷重新投递,缺载荷的历史作业保持失败。",
21702170 "operationId": "requeue_failed",
21712171 "parameters": [
21722172 {
Modified app/aihost/src/main.rs View app/aihost/src/main.rs
@@ -138,13 +138,14 @@
138138 info!(slot, "ai consumer stopped");
139139}
140140
141/// 启动时收敛上次进程留下的 pending/running 作业。
141/// 启动时收敛上次进程留下的 running 作业。
142142///
143/// 队列是 Redis 列表且没有投递确认,无法判断载荷是否还在,因此这里只把
144/// 超额的 running 记录标为失败,避免界面永远显示“进行中”。
143/// 队列是 Redis 列表且没有投递确认:进程在 `BRPOP` 之后崩溃,载荷已经离开
144/// 队列,仅把数据库状态改回 pending 不会有任何消费者再取到它。这里用提交时
145/// 持久化的载荷重新投递,投不出去的保持 pending,等待管理端手动重试。
145146async fn requeue_stale_jobs(service: &AgentService) {
146147 use models::ai::ai_job::{self, AiJobStatus};
147 use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
148 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
148149 let stale = ai_job::Entity::find()
149150 .filter(ai_job::Column::Status.eq(AiJobStatus::Running))
150151 .all(service.db_write())
@@ -157,11 +158,16 @@
157158 }
158159 };
159160 for row in rows {
160 let mut model: ai_job::ActiveModel = row.into();
161 model.status = Set(AiJobStatus::Pending);
162 model.updated_at = Set(Some(time::OffsetDateTime::now_utc()));
163 if let Err(error) = model.update(service.db_write()).await {
164 warn!(%error, "stale ai job reset failed");
161 match git::sync::retry_ai_job(
162 service.db_write(),
163 &service.state.redis,
164 &service.config().ai,
165 &row,
166 )
167 .await
168 {
169 Ok(()) => info!(job_id = %row.id, "stale ai job requeued"),
170 Err(error) => warn!(%error, job_id = %row.id, "stale ai job requeue failed"),
165171 }
166172 }
167173}
Modified lib/admin/src/handlers/queues.rs View lib/admin/src/handlers/queues.rs
@@ -34,7 +34,7 @@
3434 post,
3535 path = "/admin/queues/{name}/requeue-failed",
3636 tag = "admin-queues",
37 description = "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks)。",
37 description = "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks/ai)。AI 队列会按持久化载荷重新投递,缺载荷的历史作业保持失败。",
3838 params(("name" = String, Path, description = "queue name")),
3939 responses(
4040 (status = 200, description = "failed items requeued", body = AppResponseData<QueueActionResp>),
Modified lib/admin/src/services/logs.rs View lib/admin/src/services/logs.rs
@@ -1,4 +1,5 @@
11use models::admin::admin_audit_log;
2use models::ai::ai_job;
23use models::chat::webhook_deliveries;
34use models::ci::workflow_run::{self, WorkflowRunState};
45use models::import::job;
@@ -133,12 +134,14 @@
133134 Some("chat_webhook") => vec!["chat_webhook"],
134135 Some("pipeline") => vec!["pipeline"],
135136 Some("system_tasks") => vec!["system_tasks"],
137 Some("ai") => vec!["ai"],
136138 _ => vec![
137139 "import",
138140 "search_index",
139141 "chat_webhook",
140142 "pipeline",
141143 "system_tasks",
144 "ai",
142145 ],
143146 };
144147
@@ -228,6 +231,27 @@
228231 });
229232 }
230233 }
234 "ai" => {
235 let mut select = ai_job::Entity::find()
236 .filter(ai_job::Column::Error.is_not_null())
237 .order_by_desc(ai_job::Column::UpdatedAt);
238 if let Some(from) = query.from {
239 select = select.filter(ai_job::Column::CreatedAt.gte(from));
240 }
241 if let Some(to) = query.to {
242 select = select.filter(ai_job::Column::CreatedAt.lte(to));
243 }
244 for row in select.limit(fetch_limit).all(self.db().read()).await? {
245 logs.push(JobLogDto {
246 source: "ai".to_string(),
247 id: row.id,
248 state: format!("{:?}", row.status).to_ascii_lowercase(),
249 error: row.error,
250 created_at: row.created_at,
251 updated_at: row.updated_at,
252 });
253 }
254 }
231255 "system_tasks" => {
232256 let mut select = system_tasks::Entity::find()
233257 .filter(system_tasks::Column::Error.is_not_null())
@@ -379,6 +403,7 @@
379403 "chat_webhook" => Some("chat_webhook"),
380404 "pipeline" => Some("pipeline"),
381405 "system_tasks" => Some("system_tasks"),
406 "ai" => Some("ai"),
382407 "git_sync" | "email" | "deletion" => None,
383408 other => {
384409 return Err(AdminError::BadRequest(format!("unknown queue: {other}")));
Modified lib/admin/src/services/queues.rs View lib/admin/src/services/queues.rs
@@ -442,21 +442,27 @@
442442 .rows_affected
443443 }
444444 QUEUE_AI => {
445 ai_job::Entity::update_many()
446 .col_expr(
447 ai_job::Column::Status,
448 Expr::value(models::ai::ai_job::AiJobStatus::Pending),
445 // 批量重排与单条重试同源:只改状态不会让消费者看到任务,
446 // 必须用持久化的载荷逐条重投;缺载荷的历史作业留失败。
447 let rows = ai_job::Entity::find()
448 .filter(ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Failed))
449 .all(self.db().read())
450 .await?;
451 let mut affected = 0u64;
452 for row in rows {
453 if git::sync::retry_ai_job(
454 self.db().write(),
455 self.redis(),
456 &self.config().ai,
457 &row,
449458 )
450 .col_expr(ai_job::Column::Error, Expr::value(Option::<String>::None))
451 .col_expr(
452 ai_job::Column::FinishedAt,
453 Expr::value(Option::<time::OffsetDateTime>::None),
454 )
455 .col_expr(ai_job::Column::UpdatedAt, Expr::value(now))
456 .filter(ai_job::Column::Status.eq(models::ai::ai_job::AiJobStatus::Failed))
457 .exec(self.db().write())
458 .await?
459 .rows_affected
459 .await
460 .is_ok()
461 {
462 affected += 1;
463 }
464 }
465 affected
460466 }
461467 _ => {
462468 return Err(AdminError::BadRequest(format!(
Modified lib/admin/src/services/tasks.rs View lib/admin/src/services/tasks.rs
@@ -801,14 +801,17 @@
801801 "only failed ai jobs can be retried".to_string(),
802802 ));
803803 }
804 // 重投队列由 aihost 侧扫描 pending 状态完成:这里先复位状态与错误,
805 // 并在同一事务语义下把任务重新入队。
806 let mut am: ai_job::ActiveModel = row.clone().into();
807 am.status = Set(models::ai::ai_job::AiJobStatus::Pending);
808 am.error = Set(None);
809 am.finished_at = Set(None);
810 am.updated_at = Set(Some(now));
811 am.update(self.db().write()).await?;
804 // 仅改数据库状态不会有消费者看到任务:必须按持久化载荷原样重投。
805 git::sync::retry_ai_job(
806 self.db().write(),
807 self.redis(),
808 &self.config().ai,
809 &row,
810 )
811 .await
812 .map_err(|error| {
813 AdminError::Internal(format!("requeue ai job: {error}"))
814 })?;
812815 "pending".to_string()
813816 }
814817 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
Modified lib/admin/Cargo.toml View lib/admin/Cargo.toml
@@ -8,6 +8,7 @@
88axum = { workspace = true }
99config = { workspace = true }
1010export = { workspace = true }
11git = { workspace = true }
1112models = { workspace = true }
1213ci = { workspace = true }
1314redis = { workspace = true }
Modified lib/git/src/sync/ai_task.rs View lib/git/src/sync/ai_task.rs
@@ -1,5 +1,6 @@
11use config::ai::AiConfig;
22use export::{AppDatabase, AppRedis};
3use models::ai::ai_job;
34use models::ai::ai_job::{ActiveModel, AiJobStatus};
45use models::git::git_tags;
56use models::repo::{repo_release, repos};
@@ -21,6 +22,13 @@
2122) -> SyncResult<Uuid> {
2223 let job_id = task.job_id();
2324 let now = OffsetDateTime::now_utc();
25 // 完整载荷随作业持久化:Redis 列表没有投递确认,管理端重试时必须
26 // 能够原样重投,而不是只把数据库状态改回 pending。
27 let payload = task
28 .encode()
29 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
30 let request = serde_json::to_value(task)
31 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
2432 ActiveModel {
2533 id: Set(job_id),
2634 repo_id: Set(task.repo_id()),
@@ -31,7 +39,7 @@
3139 model: Set(cfg.model.trim().to_string()),
3240 target_ref: Set(task.target_ref()),
3341 language: Set(task.language().unwrap_or_default().to_string()),
34 request_json: Set(None),
42 request_json: Set(Some(request)),
3543 response_json: Set(None),
3644 prompt_tokens: Set(None),
3745 completion_tokens: Set(None),
@@ -44,9 +52,6 @@
4452 .insert(db)
4553 .await?;
4654
47 let payload = task
48 .encode()
49 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
5055 let queue = SyncQueue::new(redis.clone());
5156 if let Err(error) = queue.publish_raw(&cfg.queue_topic, None, &payload).await {
5257 mark_submit_failure(db, job_id, &error.to_string()).await;
@@ -193,6 +198,122 @@
193198 Ok(Some(job_id))
194199}
195200
201/// 管理端手动重试:按持久化的载荷原样重投队列。
202///
203/// 队列是 Redis 列表且没有投递确认:仅把 `ai_job.status` 改回 `pending`
204/// 不会有任何消费者看到它,所以重试必须重新 `LPUSH` 原始载荷。载荷来自
205/// 提交时的持久化副本;历史记录没有副本时按作业字段重建,重建失败才判失败。
206pub async fn retry_ai_job(
207 db: &DatabaseConnection,
208 redis: &AppRedis,
209 cfg: &AiConfig,
210 row: &ai_job::Model,
211) -> SyncResult<()> {
212 // 优先使用提交时持久化的原始载荷;历史记录没有载荷时按作业字段重建,
213 // 否则管理端对旧失败任务的重试永远无法生效。
214 let task = match row.request_json.as_ref() {
215 Some(value) => serde_json::from_value::<ci_protocol::ai::AgentTask>(value.clone())
216 .map_err(|e| SyncError::Internal(format!("decode stored ai task: {e}")))?,
217 None => match reconstruct_task(db, row).await {
218 Some(task) => task,
219 None => {
220 let reason = "stored payload missing and task cannot be reconstructed".to_string();
221 mark_retry_failure(db, row.id, &reason).await;
222 return Err(SyncError::Internal(reason));
223 }
224 },
225 };
226 let payload = task
227 .encode()
228 .map_err(|e| SyncError::Internal(format!("encode ai task: {e}")))?;
229 // 先复位再投递:消费者只能看到投递之后的作业,避免 running 被改回 pending。
230 let now = OffsetDateTime::now_utc();
231 if let Some(current) = ai_job::Entity::find_by_id(row.id).one(db).await? {
232 let mut model: ActiveModel = current.into();
233 model.status = Set(AiJobStatus::Pending);
234 model.attempts = Set(0);
235 model.error = Set(None);
236 model.started_at = Set(None);
237 model.finished_at = Set(None);
238 model.updated_at = Set(Some(now));
239 model.update(db).await?;
240 }
241 let queue = SyncQueue::new(redis.clone());
242 if let Err(error) = queue.publish_raw(&cfg.queue_topic, None, &payload).await {
243 mark_retry_failure(db, row.id, &error.to_string()).await;
244 return Err(error);
245 }
246 tracing::info!(job_id = %row.id, kind = %row.kind.as_str(), "ai job requeued by admin");
247 Ok(())
248}
249
250/// 历史作业没有 `request_json` 时按已存字段重建队列载荷。
251///
252/// 能唯一确定任务身份的信息都在 `ai_job` 上(kind / repo / target / ref /
253/// language);release 的区间起点需要回查实体,缺失时按整个历史生成。
254async fn reconstruct_task(
255 db: &DatabaseConnection,
256 row: &ai_job::Model,
257) -> Option<ci_protocol::ai::AgentTask> {
258 use models::ai::ai_job::AiJobKind;
259 let language = if row.language.trim().is_empty() {
260 None
261 } else {
262 Some(row.language.clone())
263 };
264 let target_ref = row.target_ref.clone()?;
265 match row.kind {
266 AiJobKind::PullReview => Some(ci_protocol::ai::AgentTask::PullReview {
267 job_id: row.id,
268 repo_id: row.repo_id,
269 pull_request_id: row.target_id?,
270 head_sha: target_ref,
271 language,
272 }),
273 AiJobKind::CommitReview => Some(ci_protocol::ai::AgentTask::CommitReview {
274 job_id: row.id,
275 repo_id: row.repo_id,
276 commit_sha: target_ref,
277 language,
278 }),
279 AiJobKind::RepoSummary => Some(ci_protocol::ai::AgentTask::RepoSummary {
280 job_id: row.id,
281 repo_id: row.repo_id,
282 head_sha: target_ref,
283 base_sha: None,
284 limit: None,
285 language,
286 }),
287 AiJobKind::ReleaseNotes => {
288 let release = repo_release::Entity::find_by_id(row.target_id?)
289 .one(db)
290 .await
291 .ok()??;
292 Some(ci_protocol::ai::AgentTask::ReleaseNotes {
293 job_id: row.id,
294 repo_id: row.repo_id,
295 release_id: release.id,
296 tag_name: release.tag_name,
297 from_sha: None,
298 to_sha: release.sha,
299 language,
300 })
301 }
302 }
303}
304
305async fn mark_retry_failure(db: &DatabaseConnection, job_id: Uuid, error: &str) {
306 let now = OffsetDateTime::now_utc();
307 if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id).one(db).await {
308 let mut model: ActiveModel = row.into();
309 model.status = Set(AiJobStatus::Failed);
310 model.error = Set(Some(error.to_string()));
311 model.finished_at = Set(Some(now));
312 model.updated_at = Set(Some(now));
313 let _ = model.update(db).await;
314 }
315}
316
196317/// 草稿发布者的兜底:无推送者(如系统同步)时取空间所有者。
197318async fn repo_publisher_fallback(db: &DatabaseConnection, repo_id: Uuid) -> SyncResult<Uuid> {
198319 let repo = repos::Entity::find_by_id(repo_id)
Modified lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -19,7 +19,7 @@
1919pub mod worker;
2020
2121pub use ai_language::actor_language;
22pub use ai_task::{submit_ai_task, submit_ai_task_logged};
22pub use ai_task::{retry_ai_job, submit_ai_task, submit_ai_task_logged};
2323pub use error::{SyncError, SyncResult};
2424pub use event::{
2525 EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive,
Modified web/src/app/styles/workspace.css View web/src/app/styles/workspace.css
@@ -205,3 +205,59 @@
205205@media (prefers-reduced-motion: reduce) {
206206 .workspace-shell *, .workspace-overlay * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; transition-delay: 0s !important; scroll-behavior: auto !important; }
207207}
208
209/* AI 生成的 Markdown(总结、评审结论、release 正文)。语义与 README 的
210 * markdown-body 一致,但作用域是应用内部,不依赖服务端片段样式。 */
211.gitdata-markdown { font-size: 13px; line-height: 1.65; color: var(--shell-ink); word-break: break-word; }
212.gitdata-markdown-compact { font-size: 12.5px; }
213.gitdata-markdown > :first-child { margin-top: 0; }
214.gitdata-markdown > :last-child { margin-bottom: 0; }
215.gitdata-markdown h1,
216.gitdata-markdown h2 {
217 border-bottom: 1px solid var(--shell-hairline);
218 padding-bottom: .25em;
219 margin: .9em 0 .4em;
220 font-weight: 700;
221}
222.gitdata-markdown h1 { font-size: 1.35em; }
223.gitdata-markdown h2 { font-size: 1.2em; }
224.gitdata-markdown h3 { font-size: 1.08em; font-weight: 700; margin: .9em 0 .35em; }
225.gitdata-markdown h4,
226.gitdata-markdown h5,
227.gitdata-markdown h6 { font-weight: 700; margin: .8em 0 .3em; }
228.gitdata-markdown p { margin: .5em 0; }
229.gitdata-markdown ul,
230.gitdata-markdown ol { padding-left: 1.5em; margin: .5em 0; }
231.gitdata-markdown ul { list-style: disc; }
232.gitdata-markdown ol { list-style: decimal; }
233.gitdata-markdown li { margin: .15em 0; }
234.gitdata-markdown blockquote {
235 margin: .6em 0;
236 padding: .15em .9em;
237 border-left: 3px solid var(--shell-active);
238 color: var(--shell-ink-muted);
239}
240.gitdata-markdown a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
241.gitdata-markdown code {
242 background: var(--shell-field);
243 border: 1px solid var(--shell-hairline);
244 border-radius: 4px;
245 padding: .05em .35em;
246 font-family: ui-monospace, monospace;
247 font-size: .92em;
248}
249.gitdata-markdown pre {
250 background: var(--shell-field);
251 border: 1px solid var(--shell-hairline);
252 border-radius: 8px;
253 padding: 10px 12px;
254 overflow-x: auto;
255 margin: .6em 0;
256}
257.gitdata-markdown pre code { background: none; border: none; padding: 0; font-size: 12px; }
258.gitdata-markdown table { border-collapse: collapse; margin: .6em 0; display: block; overflow-x: auto; }
259.gitdata-markdown th,
260.gitdata-markdown td { border: 1px solid var(--shell-hairline); padding: 5px 9px; }
261.gitdata-markdown th { background: var(--shell-field); font-weight: 600; }
262.gitdata-markdown img { max-width: 100%; border-radius: 6px; }
263.gitdata-markdown hr { border: none; border-top: 1px solid var(--shell-hairline); margin: 1em 0; }
No newline at end of file
Modified web/src/features/pull-request/ui/ai-tab.tsx View web/src/features/pull-request/ui/ai-tab.tsx
@@ -1,6 +1,6 @@
11import { useState } from 'react'
22import { Bot, Loader2, RefreshCw, Sparkles } from 'lucide-react'
3import { Button, ErrorPanel, PanelNote } from '@/shared/ui'
3import { Button, ErrorPanel, Markdown, PanelNote } from '@/shared/ui'
44import { fmtRelative, useAsync } from '@/shared/lib'
55import {
66 LanguagePicker,
@@ -94,7 +94,7 @@
9494 {review.truncated ? <span className="text-[11.5px] text-[#8a6100]">变更内容被截断</span> : null}
9595 </header>
9696 <div className="space-y-3 px-4 py-3">
97 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">{review.summary || '(无总结)'}</p>
97 <Markdown>{review.summary || '(无总结)'}</Markdown>
9898 {review.job_error ? <p className="text-[12px] text-[#b42318]">任务错误:{review.job_error}</p> : null}
9999 {review.findings.length > 0 ? (
100100 <ul className="space-y-2">
@@ -112,7 +112,7 @@
112112 </button>
113113 {finding.side === 'left' ? <span className="text-shell-ink-muted">旧行</span> : null}
114114 </div>
115 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-5">{finding.body}</p>
115 <Markdown compact>{finding.body}</Markdown>
116116 </li>
117117 ))}
118118 </ul>
Modified web/src/features/pull-request/ui/conversation.tsx View web/src/features/pull-request/ui/conversation.tsx
@@ -1,6 +1,6 @@
11import { useMemo, useState } from 'react'
22import { Check, CheckCheck, Lock, MessageSquare, Pencil, Reply, RotateCcw, Send, Trash2 } from 'lucide-react'
3import { Alert, Button } from '@/shared/ui'
3import { Alert, Button, Markdown } from '@/shared/ui'
44import { fmtRelative, fmtTime, useAsync } from '@/shared/lib'
55import {
66 createComment,
@@ -285,7 +285,7 @@
285285 ) : null}
286286 </span>
287287 </div>
288 {shown.content ? <p className="mt-2 whitespace-pre-wrap break-words text-[13px] leading-6 text-shell-ink">{shown.content}</p> : null}
288 {shown.content ? <Markdown className="mt-2">{shown.content}</Markdown> : null}
289289 {!full && review.comment_count > 0 ? (
290290 <button
291291 type="button"
Modified web/src/features/repo-ai/ui/ai-summary-tab.tsx View web/src/features/repo-ai/ui/ai-summary-tab.tsx
@@ -1,6 +1,6 @@
11import { useState } from 'react'
22import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react'
3import { Button, ErrorPanel, PanelNote } from '@/shared/ui'
3import { Button, ErrorPanel, Markdown, PanelNote } from '@/shared/ui'
44import { fmtRelative, useAsync } from '@/shared/lib'
55import { fetchRepoAiSummary, triggerRepoAiSummary } from '@/entities/repo'
66import { LanguagePicker } from '@/entities/pull-request'
@@ -77,7 +77,7 @@
7777 {data.pending_job_id ? <span className="text-[#8a6100]">有任务进行中</span> : null}
7878 </header>
7979 <div className="space-y-3 px-4 py-3">
80 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">{data.content}</p>
80 <Markdown>{data.content}</Markdown>
8181 </div>
8282 </section>
8383
Modified web/src/features/repo-browse/ui/repo-code-search.tsx View web/src/features/repo-browse/ui/repo-code-search.tsx
@@ -1,7 +1,34 @@
1import { useEffect, useState } from 'react'
2import { FileCode2, Search, X } from 'lucide-react'
1import { useEffect, useMemo, useRef, useState } from 'react'
2import { CornerDownLeft, FileCode2, Loader2, Search, X } from 'lucide-react'
33import { search, type SearchHit } from '@/entities/search'
44
5/** 高亮命中片段里的查询词,命中为空或含正则字符时原样返回。 */
6function highlight(text: string, query: string) {
7 const needle = query.trim()
8 if (!needle) return text
9 const parts = text.split(new RegExp(`(${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig'))
10 return parts.map((part, index) =>
11 part.toLowerCase() === needle.toLowerCase() ? (
12 <mark key={index} className="rounded bg-[#f0b232]/35 px-0.5 text-shell-ink">{part}</mark>
13 ) : (
14 <span key={index}>{part}</span>
15 ),
16 )
17}
18
19interface Group {
20 kind: SearchHit['kind']
21 label: string
22 hits: SearchHit[]
23}
24
25const KIND_LABEL: Record<SearchHit['kind'], string> = {
26 code: '代码',
27 symbol: '符号',
28 issue: 'Issue',
29 pull: 'PR',
30}
31
532export function RepoCodeSearch({
633 space,
734 repo,
@@ -19,6 +46,10 @@
1946 const [hits, setHits] = useState<SearchHit[]>([])
2047 const [loading, setLoading] = useState(false)
2148 const [error, setError] = useState('')
49 const [open, setOpen] = useState(false)
50 const [cursor, setCursor] = useState(0)
51 const root = useRef<HTMLDivElement | null>(null)
52 const inputRef = useRef<HTMLInputElement | null>(null)
2253
2354 useEffect(() => {
2455 const q = query.trim()
@@ -32,7 +63,7 @@
3263 const timer = window.setTimeout(() => {
3364 setLoading(true)
3465 setError('')
35 search({ q, kinds: ['code', 'symbol'], spaceName: space, repoName: repo, limit: 10 }).then(
66 search({ q, kinds: ['code', 'symbol'], spaceName: space, repoName: repo, limit: 20 }).then(
3667 page => {
3768 if (!active) return
3869 setHits(page.items)
@@ -52,42 +83,161 @@
5283 }
5384 }, [query, repo, space])
5485
86
87 useEffect(() => {
88 if (!open) return
89 const onPointerDown = (event: PointerEvent) => {
90 if (!root.current?.contains(event.target as Node)) setOpen(false)
91 }
92 window.addEventListener('pointerdown', onPointerDown)
93 return () => window.removeEventListener('pointerdown', onPointerDown)
94 }, [open])
95
96 // 与 GitHub 一致:不在输入框里时按 `/` 直接聚焦仓库搜索。
97 useEffect(() => {
98 const onKeyDown = (event: KeyboardEvent) => {
99 if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return
100 const target = event.target as HTMLElement | null
101 if (target?.closest('input, textarea, select, [contenteditable="true"]')) return
102 event.preventDefault()
103 inputRef.current?.focus()
104 setOpen(true)
105 }
106 window.addEventListener('keydown', onKeyDown)
107 return () => window.removeEventListener('keydown', onKeyDown)
108 }, [])
109
110 const index = useMemo(() => new Map(hits.map((hit, at) => [`${hit.kind}:${hit.id}`, at])), [hits])
111 // 结果集变化时游标自动收敛,不需要再用 effect 把 state 归零。
112 const active = hits.length ? Math.min(cursor, hits.length - 1) : 0
113 const current = hits[active] ?? null
114
115 // 结果按类型分组,和 GitHub 的仓库内搜索一样先给代码、再给符号。
116 const groups: Group[] = (['code', 'symbol'] as const)
117 .map(kind => ({
118 kind,
119 label: KIND_LABEL[kind],
120 hits: hits.filter(hit => hit.kind === kind),
121 }))
122 .filter(group => group.hits.length > 0)
123
124 const move = (delta: number) => {
125 if (!hits.length) return
126 const next = (active + delta + hits.length) % hits.length
127 setCursor(next)
128 const key = `${hits[next].kind}:${hits[next].id}`
129 document.getElementById(`repo-search-hit-${key}`)?.scrollIntoView({ block: 'nearest' })
130 }
131
55132 const indexedBranch = currentRevision === defaultBranch || !currentRevision
133 const panelOpen = open && Boolean(query.trim())
134
56135 return (
57 <div className="relative mb-3">
58 <div className="flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-surface px-2.5 shadow-sm focus-within:ring-2 focus-within:ring-shell-tile/60">
59 <Search className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
136 <div ref={root} className="relative mb-3">
137 <div className="flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-surface px-2.5 shadow-sm transition-colors focus-within:border-shell-focus">
138 {loading ? (
139 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-shell-ink-muted" aria-hidden />
140 ) : (
141 <Search className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
142 )}
60143 <input
144 ref={inputRef}
61145 value={query}
62 onChange={event => setQuery(event.target.value)}
63 placeholder="搜索当前仓库代码…"
146 onChange={event => {
147 setQuery(event.target.value)
148 setOpen(true)
149 }}
150 onFocus={() => setOpen(true)}
151 onKeyDown={event => {
152 if (event.key === 'ArrowDown') {
153 event.preventDefault()
154 move(1)
155 } else if (event.key === 'ArrowUp') {
156 event.preventDefault()
157 move(-1)
158 } else if (event.key === 'Enter' && current) {
159 event.preventDefault()
160 onOpen(current)
161 setOpen(false)
162 } else if (event.key === 'Escape') {
163 setOpen(false)
164 }
165 }}
166 placeholder="在仓库中搜索代码或符号…"
64167 aria-label="搜索当前仓库代码"
65 className="h-9 min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-shell-ink-muted"
168 role="combobox"
169 aria-expanded={panelOpen}
170 aria-controls="repo-search-results"
171 className="h-10 min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-shell-ink-muted"
66172 />
67173 {query ? (
68 <button type="button" onClick={() => setQuery('')} className="rounded p-1 text-shell-ink-muted hover:bg-shell-field hover:text-shell-ink" aria-label="清除仓库搜索">
174 <button type="button" onClick={() => { setQuery(''); setOpen(false) }} className="rounded p-1 text-shell-ink-muted hover:bg-shell-field hover:text-shell-ink" aria-label="清除仓库搜索">
69175 <X className="h-3.5 w-3.5" aria-hidden />
70176 </button>
71 ) : null}
177 ) : (
178 <kbd className="hidden shrink-0 rounded border border-shell-hairline bg-shell-field px-1.5 py-0.5 font-mono text-[10px] text-shell-ink-muted sm:block">/</kbd>
179 )}
72180 </div>
73181 {!indexedBranch ? <p className="mt-1 px-1 text-[11px] text-shell-ink-muted">结果来自默认分支 {defaultBranch}</p> : null}
74 {(loading || error || hits.length > 0) && query ? (
75 <div className="absolute inset-x-0 z-20 mt-1 overflow-hidden rounded-lg border border-shell-hairline bg-shell-surface shadow-lg">
76 {loading ? <p className="px-3 py-2 text-[12px] text-shell-ink-muted">正在搜索…</p> : null}
77 {error ? <p role="alert" className="px-3 py-2 text-[12px] text-[#b42318]">{error}</p> : null}
78 {!loading && !error && hits.length === 0 ? <p className="px-3 py-2 text-[12px] text-shell-ink-muted">没有匹配的代码或符号</p> : null}
79 {hits.map(hit => (
80 <button key={`${hit.kind}:${hit.id}`} type="button" onClick={() => onOpen(hit)} className="flex w-full gap-2 border-t border-shell-hairline/70 px-3 py-2 text-left hover:bg-shell-field focus:bg-shell-field focus:outline-none">
81 <FileCode2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
82 <span className="min-w-0">
83 <span className="block truncate text-[12.5px] font-semibold">{hit.title}</span>
84 <span className="block truncate font-mono text-[11px] text-shell-ink-muted">{hit.path}{hit.line_start ? `:${hit.line_start}` : ''}</span>
85 {hit.snippet ? <span className="mt-0.5 block line-clamp-1 text-[11px] text-shell-ink-muted">{hit.snippet}</span> : null}
86 </span>
87 </button>
182 {panelOpen ? (
183 <div
184 id="repo-search-results"
185 role="listbox"
186 className="absolute inset-x-0 z-30 mt-1 max-h-[min(60vh,480px)] overflow-y-auto overscroll-contain rounded-lg border border-shell-hairline bg-shell-surface py-1 shadow-xl"
187 >
188 {loading ? <p className="px-3 py-4 text-[12px] text-shell-ink-muted">正在搜索…</p> : null}
189 {error ? <p role="alert" className="px-3 py-4 text-[12px] text-[#b42318]">{error}</p> : null}
190 {!loading && !error && hits.length === 0 ? (
191 <p className="px-3 py-6 text-center text-[12px] text-shell-ink-muted">没有匹配的代码或符号</p>
192 ) : null}
193 {groups.map(group => (
194 <div key={group.kind}>
195 <p className="px-3 pb-1 pt-2 font-mono text-[10px] font-semibold uppercase tracking-[1.2px] text-shell-ink-muted">
196 {group.label} · {group.hits.length}
197 </p>
198 {group.hits.map(hit => {
199 const at = index.get(`${hit.kind}:${hit.id}`) ?? -1
200 const selected = at === active
201 return (
202 <button
203 id={`repo-search-hit-${hit.kind}:${hit.id}`}
204 key={`${hit.kind}:${hit.id}`}
205 type="button"
206 role="option"
207 aria-selected={selected}
208 onMouseEnter={() => setCursor(at)}
209 onClick={() => { onOpen(hit); setOpen(false) }}
210 className={`flex w-full items-start gap-2.5 px-3 py-2 text-left transition-colors ${selected ? 'bg-shell-active' : 'hover:bg-shell-field'}`}
211 >
212 <FileCode2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
213 <span className="min-w-0 flex-1">
214 <span className="flex items-baseline gap-2">
215 <span className="min-w-0 truncate text-[12.5px] font-semibold">{highlight(hit.title, query)}</span>
216 {hit.symbol_name ? <span className="shrink-0 text-[11px] text-shell-ink-muted">{hit.symbol_name}</span> : null}
217 </span>
218 <span className="mt-0.5 block truncate font-mono text-[11px] text-shell-ink-muted">
219 {hit.path}
220 {hit.line_start ? `:${hit.line_start}` : ''}
221 </span>
222 {hit.snippet ? (
223 <span className="mt-1 block truncate font-mono text-[11px] leading-4 text-shell-ink-muted">
224 {highlight(hit.snippet, query)}
225 </span>
226 ) : null}
227 </span>
228 {selected ? <CornerDownLeft className="mt-1 h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden /> : null}
229 </button>
230 )
231 })}
232 </div>
88233 ))}
234 {hits.length > 0 ? (
235 <p className="border-t border-shell-hairline px-3 py-2 text-[11px] text-shell-ink-muted">
236 ↑↓ 选择 · Enter 打开 · Esc 关闭
237 </p>
238 ) : null}
89239 </div>
90240 ) : null}
91241 </div>
92242 )
93}
243}
No newline at end of file
Modified web/src/features/repo-history/ui/commit-insights.tsx View web/src/features/repo-history/ui/commit-insights.tsx
@@ -1,24 +1,25 @@
1import { useMemo, useState } from 'react'
2import { MessageSquareText, Sparkles, Trash2 } from 'lucide-react'
3import { Button, PanelNote, modalTextareaClass } from '@/shared/ui'
4import { listCodeComments, createCodeComment, deleteCodeComment, resolveCodeComment } from '@/entities/repo'
1import { useState } from 'react'
2import { Sparkles, Trash2 } from 'lucide-react'
3import { Button, Markdown, modalTextareaClass } from '@/shared/ui'
4import {
5 createCodeComment,
6 deleteCodeComment,
7 resolveCodeComment,
8 unresolveCodeComment,
9 type CodeComment,
10} from '@/entities/repo'
511import { LanguagePicker, listCommitAiReviews, severityTone, triggerCommitAiReview, type CommitAiReview } from '@/entities/pull-request'
612import { useAsync } from '@/shared/lib'
713
814export interface CommitInsightsProps {
915 space: string
1016 repo: string
11 /** Commit being viewed; line comments are anchored to it. */
17 /** Commit being viewed; AI 评审同样锚定该提交。 */
1218 sha: string
1319 onChanged?: () => void
1420}
1521
16/**
17 * 提交页的行级评论 + AI 评审面板。
18 *
19 * 行级评论锚定到具体提交(`code_comment.commit_sha`),提交被改写或分支前进后
20 * 仍保留原锚点并标记 outdated,而不是静默重定位。
21 */
22/** 提交页的 AI 评审面板。行级评论不在这里:它们由差异视图行内渲染。 */
2223export function CommitInsights({ space, repo, sha, onChanged }: CommitInsightsProps) {
2324 const [tick, setTick] = useState(0)
2425 const [busy, setBusy] = useState(false)
@@ -26,29 +27,9 @@
2627 /** '' = 跟随当前用户偏好。 */
2728 const [language, setLanguage] = useState('')
2829
29 const comments = useAsync(
30 () => listCodeComments(space, repo, { revision: sha, limit: 200 }),
31 [space, repo, sha, tick],
32 )
3330 const reviews = useAsync(() => listCommitAiReviews(space, repo, sha), [space, repo, sha, tick])
34
35 const rows = useMemo(() => comments.data?.items ?? [], [comments.data])
3631 const aiRows: CommitAiReview[] = reviews.data ?? []
3732
38 async function remove(id: string) {
39 setBusy(true)
40 setError(null)
41 try {
42 await deleteCodeComment(space, repo, id)
43 setTick(t => t + 1)
44 onChanged?.()
45 } catch (f) {
46 setError(f instanceof Error ? f.message : '删除评论失败')
47 } finally {
48 setBusy(false)
49 }
50 }
51
5233 async function rerunAi() {
5334 setBusy(true)
5435 setError(null)
@@ -58,19 +39,6 @@
5839 onChanged?.()
5940 } catch (f) {
6041 setError(f instanceof Error ? f.message : '触发 AI 评审失败')
61 } finally {
62 setBusy(false)
63 }
64 }
65
66 async function resolve(id: string) {
67 setBusy(true)
68 setError(null)
69 try {
70 await resolveCodeComment(space, repo, id)
71 setTick(t => t + 1)
72 } catch (f) {
73 setError(f instanceof Error ? f.message : '更新评论状态失败')
7442 } finally {
7543 setBusy(false)
7644 }
@@ -80,15 +48,23 @@
8048 <div className="space-y-3">
8149 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
8250
83 {aiRows.length > 0 ? (
51 {aiRows.length === 0 ? (
52 <section className="rounded-xl border border-shell-hairline bg-shell-surface px-4 py-3">
53 <div className="flex items-center gap-2">
54 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
55 <span className="text-[13px] font-medium">AI 评审</span>
56 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
57 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
58 重新评审
59 </Button>
60 </div>
61 <p className="mt-2 text-[12.5px] text-shell-ink-muted">该提交还没有 AI 评审结果。</p>
62 </section>
63 ) : (
8464 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
85 <header className="flex items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
65 <header className="flex flex-wrap items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
8666 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
8767 <span className="font-medium">AI 评审</span>
88 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
89 重新评审
90 </Button>
91 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
9268 <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
9369 aiRows[0].verdict === 'changes_requested'
9470 ? 'text-[#b42318] bg-[rgba(242,63,67,0.10)]'
@@ -98,9 +74,13 @@
9874 </span>
9975 {aiRows[0].language ? <span className="text-[11.5px] text-shell-ink-muted">{aiRows[0].language}</span> : null}
10076 {aiRows[0].truncated ? <span className="text-[11.5px] text-[#8a6100]">内容被截断</span> : null}
77 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
78 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
79 重新评审
80 </Button>
10181 </header>
10282 <div className="space-y-2 px-4 py-3">
103 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">{aiRows[0].summary}</p>
83 <Markdown>{aiRows[0].summary || '(无总结)'}</Markdown>
10484 {aiRows[0].findings.map((finding, index) => (
10585 <div key={`${finding.path}:${finding.line}:${index}`} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
10686 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
@@ -108,54 +88,76 @@
10888 <span className="font-mono">{finding.path}:{finding.line}</span>
10989 {finding.side === 'left' ? <span className="text-shell-ink-muted">旧行</span> : null}
11090 </div>
111 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-5">{finding.body}</p>
91 <Markdown compact>{finding.body}</Markdown>
11292 </div>
11393 ))}
11494 </div>
11595 </section>
116 ) : null}
96 )}
97 </div>
98 )
99}
117100
118 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
119 <header className="flex items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
120 <MessageSquareText className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
121 <span className="font-medium">行级评论</span>
122 <span className="text-shell-ink-muted">{rows.length} 条</span>
123 </header>
124 <div className="space-y-2 px-4 py-3">
125 {rows.length === 0 ? (
126 <PanelNote text="该提交还没有行级评论。在下方差异中把鼠标移到行号上即可评论。" />
127 ) : (
128 rows.map(row => (
129 <div key={row.id} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
130 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
131 <span className="font-mono">{row.path}:{row.line}</span>
132 <span className="text-shell-ink-muted">{row.author?.display_name || row.author?.username || '未知用户'}</span>
133 {row.resolved ? <span className="text-[#1a7f37]">已解决</span> : null}
134 {row.outdated ? <span className="text-[#8a6100]">已过期</span> : null}
135 <button
136 type="button"
137 disabled={busy}
138 onClick={() => void resolve(row.id)}
139 className="ml-auto text-shell-ink-muted hover:text-shell-ink"
140 >
141 {row.resolved ? '取消解决' : '标记解决'}
142 </button>
143 <button
144 type="button"
145 disabled={busy}
146 onClick={() => void remove(row.id)}
147 className="text-shell-ink-muted hover:text-[#b42318]"
148 aria-label="删除评论"
149 >
150 <Trash2 className="h-3.5 w-3.5" aria-hidden />
151 </button>
152 </div>
153 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-5">{row.body}</p>
154 </div>
155 ))
101/** 行内评论卡片:钉在差异行下方,带作者、过期标记与解决/删除操作。 */
102export function CodeLineCommentCard({
103 space,
104 repo,
105 comment,
106 onChanged,
107}: {
108 space: string
109 repo: string
110 comment: CodeComment
111 onChanged: () => void
112}) {
113 const [working, setWorking] = useState(false)
114 const [error, setError] = useState<string | null>(null)
115
116 async function run(fn: () => Promise<unknown>, failure: string) {
117 setWorking(true)
118 setError(null)
119 try {
120 await fn()
121 onChanged()
122 } catch (f) {
123 setError(f instanceof Error ? f.message : failure)
124 } finally {
125 setWorking(false)
126 }
127 }
128
129 return (
130 <div className="border-t border-shell-hairline bg-shell-field/60 px-3 py-2 font-sans">
131 <div className="mb-1 flex flex-wrap items-center gap-2 font-sans text-[11.5px]">
132 <span className="font-medium text-shell-ink">{comment.author?.display_name || comment.author?.username || '未知用户'}</span>
133 <span className="font-mono text-shell-ink-muted">{comment.path}:{comment.line}</span>
134 {comment.resolved ? <span className="text-[#1a7f37]">已解决</span> : null}
135 {comment.outdated ? <span className="text-[#8a6100]">已过期</span> : null}
136 <button
137 type="button"
138 disabled={working}
139 onClick={() => void run(
140 () => (comment.resolved
141 ? unresolveCodeComment(space, repo, comment.id)
142 : resolveCodeComment(space, repo, comment.id)),
143 '更新评论状态失败',
156144 )}
157 </div>
158 </section>
145 className="ml-auto text-shell-ink-muted hover:text-shell-ink"
146 >
147 {comment.resolved ? '取消解决' : '标记解决'}
148 </button>
149 <button
150 type="button"
151 disabled={working}
152 onClick={() => void run(() => deleteCodeComment(space, repo, comment.id), '删除评论失败')}
153 className="text-shell-ink-muted hover:text-[#b42318]"
154 aria-label="删除评论"
155 >
156 <Trash2 className="h-3.5 w-3.5" aria-hidden />
157 </button>
158 </div>
159 <p className="whitespace-pre-wrap break-words font-sans text-[12.5px] leading-5 text-shell-ink">{comment.body}</p>
160 {error ? <p className="mt-1 text-[11.5px] text-[#b42318]" role="alert">{error}</p> : null}
159161 </div>
160162 )
161163}
@@ -219,5 +221,4 @@
219221 </div>
220222 </div>
221223 )
222}
223
224}
No newline at end of file
Modified web/src/features/repo-history/ui/commit-view.tsx View web/src/features/repo-history/ui/commit-view.tsx
@@ -2,7 +2,7 @@
22import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react'
33import { Button, CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
44import { fmtRelative, fmtTime, repoKey, settingsKey, shortSha, useAsync } from '@/shared/lib'
5import { CommitInsights, LineCommentComposer } from './commit-insights'
5import { CodeLineCommentCard, CommitInsights, LineCommentComposer } from './commit-insights'
66import {
77 applyNumstat,
88 EMPTY_TREE,
@@ -10,9 +10,11 @@
1010 fetchDiffStyle,
1111 fetchNumstat,
1212 fetchRawDiff,
13 listCodeComments,
1314 parseHunks,
1415 splitDiffFiles,
1516 toSplitRows,
17 type CodeComment,
1618 type DiffFile,
1719 type DiffLine,
1820 type DiffStyle,
@@ -67,6 +69,7 @@
6769 onDiffStyleChange,
6870}: CommitViewProps) {
6971 const [retry, setRetry] = useState(0)
72 const [commentsVersion, setCommentsVersion] = useState(0)
7073 const [expanded, setExpanded] = useState<Record<string, boolean>>({})
7174 // The toolbar toggle writes the route, so a shared link carries the layout;
7275 // no override in the URL means "follow the saved appearance preference".
@@ -90,6 +93,22 @@
9093 cacheKey: repoKey(space, repo, `commit:${sha}`),
9194 })
9295
96 // 行级评论锚定该提交,按「文件 + 显示行号」归组后行内渲染。
97 const comments = useAsync(
98 () => listCodeComments(space, repo, { revision: sha, limit: 200 }),
99 [space, repo, sha, commentsVersion],
100 )
101 const commentsByLine = useMemo(() => {
102 const map = new Map<string, CodeComment[]>()
103 for (const row of comments.data?.items ?? []) {
104 const key = `${row.path}#${row.line}`
105 const list = map.get(key) ?? []
106 list.push(row)
107 map.set(key, list)
108 }
109 return map
110 }, [comments.data])
111
93112 const data = state.data
94113 const files = data?.files ?? []
95114 const additions = files.reduce((sum, file) => sum + file.additions, 0)
@@ -199,7 +218,7 @@
199218 space={space}
200219 repo={repo}
201220 sha={data.commit.id}
202 onChanged={() => setRetry(r => r + 1)}
221 onChanged={() => setCommentsVersion(v => v + 1)}
203222 />
204223
205224 {files.length === 0 ? <PanelNote text="该提交没有文件改动(可能是空提交或合并提交)。" /> : null}
@@ -240,7 +259,8 @@
240259 space={space}
241260 repo={repo}
242261 commitSha={data.commit.id}
243 onCommented={() => setRetry(r => r + 1)}
262 commentsByLine={commentsByLine}
263 onCommented={() => setCommentsVersion(v => v + 1)}
244264 />
245265 ) : null}
246266 </section>
@@ -259,6 +279,7 @@
259279 space,
260280 repo,
261281 commitSha,
282 commentsByLine,
262283 onCommented,
263284}: {
264285 file: DiffFile
@@ -266,6 +287,7 @@
266287 space: string
267288 repo: string
268289 commitSha: string
290 commentsByLine: Map<string, CodeComment[]>
269291 onCommented: () => void
270292}) {
271293 // 当前展开的评论输入框锚点(同一时刻只开一个)。
@@ -274,8 +296,59 @@
274296 const { hunks, truncated } = useMemo(() => parseHunks(file.raw), [file.raw])
275297 if (file.status === 'binary') return <PanelNote text="二进制文件,不显示差异内容。" />
276298 if (hunks.length === 0) return <PanelNote text="仅权限或元数据变更。" />
299
300 const threadsAt = (line: number) => commentsByLine.get(`${file.path}#${line}`) ?? []
301 const hasThread = (line: number | null | undefined) =>
302 line != null && (threadsAt(line).length > 0 || draftLine === line)
303 const commentsAt = (line: number | null | undefined) =>
304 line == null ? 0 : threadsAt(line).length
305 // 评论与输入框都挂在行下方,而不是汇总到一个独立的面板里。
306 const renderThreads = (numbers: Array<number | null | undefined>) => {
307 const unique = [...new Set(numbers.filter((value): value is number => value != null))]
308 return (
309 <>
310 {unique.flatMap(line =>
311 threadsAt(line).map(comment => (
312 <CodeLineCommentCard
313 key={comment.id}
314 space={space}
315 repo={repo}
316 comment={comment}
317 onChanged={onCommented}
318 />
319 )),
320 )}
321 {unique.map(line =>
322 draftLine === line ? (
323 <LineCommentComposer
324 key={`draft:${line}`}
325 space={space}
326 repo={repo}
327 path={file.path}
328 line={line}
329 commitSha={commitSha}
330 onCancel={() => setDraftLine(null)}
331 onDone={() => {
332 setDraftLine(null)
333 onCommented()
334 }}
335 />
336 ) : null,
337 )}
338 </>
339 )
340 }
341
277342 if (style === 'split') {
278 return <SplitDiff hunks={hunks} truncated={truncated} onRequestComment={setDraftLine} />
343 return (
344 <SplitDiff
345 hunks={hunks}
346 truncated={truncated}
347 onRequestComment={setDraftLine}
348 hasThread={hasThread}
349 renderThreads={renderThreads}
350 />
351 )
279352 }
280353 return (
281354 <div className="overflow-x-auto">
@@ -292,23 +365,10 @@
292365 const lineNo = line.kind === 'del' ? line.oldNo : line.newNo
293366 return (
294367 <Fragment key={`${hunk.header}:${index}`}>
295 <DiffRow line={line} onRequestComment={setDraftLine} />
296 {draftLine != null && lineNo === draftLine ? (
368 <DiffRow line={line} onRequestComment={setDraftLine} commentCount={commentsAt(lineNo)} />
369 {hasThread(lineNo) ? (
297370 <tr>
298 <td colSpan={4} className="p-0">
299 <LineCommentComposer
300 space={space}
301 repo={repo}
302 path={file.path}
303 line={draftLine}
304 commitSha={commitSha}
305 onCancel={() => setDraftLine(null)}
306 onDone={() => {
307 setDraftLine(null)
308 onCommented()
309 }}
310 />
311 </td>
371 <td colSpan={4} className="p-0">{renderThreads([lineNo])}</td>
312372 </tr>
313373 ) : null}
314374 </Fragment>
@@ -327,7 +387,15 @@
327387 )
328388}
329389
330function DiffRow({ line, onRequestComment }: { line: DiffLine; onRequestComment?: (line: number) => void }) {
390function DiffRow({
391 line,
392 onRequestComment,
393 commentCount = 0,
394}: {
395 line: DiffLine
396 onRequestComment?: (line: number) => void
397 commentCount?: number
398}) {
331399 const tone =
332400 line.kind === 'add'
333401 ? 'bg-[rgba(35,165,90,0.12)]'
@@ -345,14 +413,21 @@
345413 </td>
346414 <td className="w-4 select-none px-1 text-center text-[11px] text-shell-ink-muted">
347415 {lineNo != null && onRequestComment ? (
348 <button
349 type="button"
350 title="对该行发表评论"
351 onClick={() => onRequestComment(lineNo)}
352 className="rounded bg-shell-active px-0.5 text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/row:opacity-100"
353 >
354 +
355 </button>
416 <span className="inline-flex items-center gap-0.5">
417 <button
418 type="button"
419 title={commentCount > 0 ? `该行有 ${commentCount} 条评论` : '对该行发表评论'}
420 onClick={() => onRequestComment(lineNo)}
421 className={`rounded px-0.5 transition-opacity hover:text-shell-ink focus:opacity-100 ${
422 commentCount > 0
423 ? 'bg-[#f0b232]/30 text-shell-ink'
424 : 'bg-shell-active text-shell-ink-muted opacity-0 group-hover/row:opacity-100'
425 }`}
426 >
427 +
428 </button>
429 {commentCount > 0 ? <span className="text-[10px] text-shell-ink-muted">{commentCount}</span> : null}
430 </span>
356431 ) : line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : ''}
357432 </td>
358433 <td className="whitespace-pre-wrap break-all px-2 py-0.5">{line.text || ' '}</td>
@@ -364,10 +439,14 @@
364439 hunks,
365440 truncated,
366441 onRequestComment,
442 hasThread,
443 renderThreads,
367444}: {
368445 hunks: { header: string; lines: DiffLine[] }[]
369446 truncated: boolean
370447 onRequestComment?: (line: number) => void
448 hasThread: (line: number | null | undefined) => boolean
449 renderThreads: (numbers: Array<number | null | undefined>) => React.ReactNode
371450}) {
372451 const cell = (line: DiffLine | null, side: 'old' | 'new') => {
373452 const tone =
@@ -410,12 +489,23 @@
410489 {hunk.header}
411490 </td>
412491 </tr>
413 {toSplitRows(hunk.lines).map((row, index) => (
414 <tr key={`${hunk.header}:${index}`}>
415 {cell(row.left, 'old')}
416 {cell(row.right, 'new')}
417 </tr>
418 ))}
492 {toSplitRows(hunk.lines).map((row, index) => {
493 const numbers = [row.left?.oldNo, row.left?.newNo, row.right?.oldNo, row.right?.newNo]
494 const thread = numbers.some(hasThread)
495 return (
496 <Fragment key={`${hunk.header}:${index}`}>
497 <tr>
498 {cell(row.left, 'old')}
499 {cell(row.right, 'new')}
500 </tr>
501 {thread ? (
502 <tr>
503 <td colSpan={2} className="p-0">{renderThreads(numbers)}</td>
504 </tr>
505 ) : null}
506 </Fragment>
507 )
508 })}
419509 </Fragment>
420510 ))}
421511 </tbody>
Modified web/src/features/repo-releases/ui/releases-tab.tsx View web/src/features/repo-releases/ui/releases-tab.tsx
@@ -1,12 +1,13 @@
1import { useState } from 'react'
1import { useMemo, useState } from 'react'
22import { Loader2, Pencil, Plus, Sparkles, Tag, Trash2 } from 'lucide-react'
3import { Alert, Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
3import { Alert, Button, ErrorPanel, Markdown, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
44import { fmtRelative, useAsync } from '@/shared/lib'
55import {
66 createRelease,
77 deleteRelease,
88 getRelease,
99 listReleases,
10 listTags,
1011 redraftRelease,
1112 updateRelease,
1213 type Release,
@@ -104,7 +105,10 @@
104105
105106 {creating ? (
106107 <CreateReleaseModal
108 space={space}
109 repo={repo}
107110 busy={busy}
111 taken={(list.data?.items ?? []).map(item => item.tag_name)}
108112 onClose={() => setCreating(false)}
109113 onSubmit={(tag, title, draft) =>
110114 void run(async () => {
@@ -211,9 +215,11 @@
211215 ) : (
212216 <>
213217 <h3 className="text-[15px] font-semibold">{release.title || release.tag_name}</h3>
214 <p className="whitespace-pre-wrap break-words text-[13px] leading-6">
215 {release.content || '(没有正文)'}
216 </p>
218 {release.content ? (
219 <Markdown>{release.content}</Markdown>
220 ) : (
221 <p className="text-[13px] text-shell-ink-muted">(没有正文)</p>
222 )}
217223 {canWrite ? (
218224 <p className="text-[12px] text-shell-ink-muted">已发布的发布记录正文不可再修改。</p>
219225 ) : null}
@@ -224,25 +230,68 @@
224230}
225231
226232function CreateReleaseModal({
233 space,
234 repo,
227235 busy,
236 taken,
228237 onClose,
229238 onSubmit,
230239}: {
240 space: string
241 repo: string
231242 busy: boolean
243 /** 已有 release 的标签;这些标签不能再建一条记录。 */
244 taken: string[]
232245 onClose: () => void
233246 onSubmit: (tag: string, title: string, draft: boolean) => void
234247}) {
235248 const [tag, setTag] = useState('')
236249 const [title, setTitle] = useState('')
237250 const [draft, setDraft] = useState(true)
251 // Release 只能建立在已经存在的标签上,因此标签必须来自仓库里真实的 refs,
252 // 而不是让用户手输一个可能不存在的名字。
253 const tags = useAsync(() => listTags(space, repo, 200), [space, repo, taken.join(',')])
254 const owned = useMemo(() => new Set(taken), [taken])
255 const rows = useMemo(
256 () => (tags.data?.items ?? []).filter(item => !owned.has(item.name)),
257 [tags.data, owned],
258 )
259 const empty = Boolean(tags.data) && rows.length === 0
238260 return (
239261 <Modal open title="新建发布记录" onClose={onClose}>
240262 <div className="space-y-3">
241 <Alert tone="info">标签必须已存在于仓库中;缺省会以该标签指向的提交为目标。</Alert>
263 <Alert tone="info">选择一个已存在的标签,发布记录会指向该标签对应的提交。</Alert>
242264 <label className={modalLabelClass}>
243265 标签
244 <input value={tag} onChange={event => setTag(event.target.value)} placeholder="v1.0.0" className={modalInputClass} />
266 {tags.loading && !tags.data ? (
267 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">加载标签…</span>
268 ) : empty ? (
269 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">
270 没有可用的标签。请先在「分支与标签」中创建标签,或推送一个新标签。
271 </span>
272 ) : (
273 <select
274 value={tag}
275 onChange={event => {
276 const next = event.target.value
277 setTag(next)
278 if (!title.trim()) setTitle(next)
279 }}
280 className={modalInputClass}
281 >
282 <option value="">请选择标签…</option>
283 {rows.map(item => (
284 <option key={item.name} value={item.name}>
285 {item.name}
286 {item.created_at ? `(${fmtRelative(item.created_at)})` : ''}
287 </option>
288 ))}
289 </select>
290 )}
245291 </label>
292 {tags.error ? (
293 <p className="text-[12.5px] text-[#b42318]" role="alert">标签列表加载失败,请稍后重试。</p>
294 ) : null}
246295 <label className={modalLabelClass}>
247296 标题(可选)
248297 <input value={title} onChange={event => setTitle(event.target.value)} className={modalInputClass} />
@@ -253,8 +302,8 @@
253302 </label>
254303 <div className="flex justify-end gap-2">
255304 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={onClose}>取消</Button>
256 <Button className="h-8 text-[12.5px]" disabled={busy || !tag.trim()} onClick={() => onSubmit(tag.trim(), title.trim() || tag.trim(), draft)}>
257 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Pencil className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
305 <Button className="h-8 text-[12.5px]" disabled={busy || !tag} onClick={() => onSubmit(tag, title.trim() || tag, draft)}>
306 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Pencil className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />}
258307 创建
259308 </Button>
260309 </div>
Modified web/src/shared/ui/index.ts View web/src/shared/ui/index.ts
@@ -10,6 +10,7 @@
1010export { RevealToggle, type RevealToggleProps } from './reveal-toggle'
1111export { Alert, type AlertProps, type AlertTone } from './alert'
1212export { PanelNote, ErrorPanel, CopyChip } from './panels'
13export { Markdown } from './markdown'
1314export { AppShell, ShellTopBar, type AppShellProps, type ShellTopBarProps } from './app-shell'
1415export {
1516 FormModal,
Added web/src/shared/ui/markdown.tsx View web/src/shared/ui/markdown.tsx
@@ -0,0 +1,34 @@
1import { useMemo } from 'react'
2import DOMPurify from 'dompurify'
3import { marked } from 'marked'
4
5marked.use({ gfm: true, breaks: true })
6
7/**
8 * AI 生成的 Markdown 渲染器。
9 *
10 * 模型输出的是 Markdown(总结、评审结论、release 正文),必须按 Markdown
11 * 渲染而不是当纯文本显示。模型输出属于不可信内容,因此渲染结果一律经
12 * DOMPurify 清洗:脚本、事件属性与 `javascript:` 链接都会被剥离。
13 */
14export function Markdown({
15 children,
16 className = '',
17 compact = false,
18}: {
19 children: string
20 className?: string
21 /** 行内发现等小字号场景。 */
22 compact?: boolean
23}) {
24 const html = useMemo(
25 () => DOMPurify.sanitize(marked.parse(children, { async: false })),
26 [children],
27 )
28 return (
29 <div
30 className={`gitdata-markdown ${compact ? 'gitdata-markdown-compact' : ''} ${className}`}
31 dangerouslySetInnerHTML={{ __html: html }}
32 />
33 )
34}
No newline at end of file
Modified web/bun.lock View web/bun.lock
@@ -8,7 +8,9 @@
88 "@alova/wormhole": "^1.5.2",
99 "@tailwindcss/vite": "^4.3.3",
1010 "alova": "^3.5.5",
11 "dompurify": "^3.4.15",
1112 "lucide-react": "^1.39.0",
13 "marked": "^18.0.13",
1214 "react": "^19.2.8",
1315 "react-dom": "^19.2.8",
1416 "react-router-dom": "^7.18.3",
@@ -225,6 +227,8 @@
225227
226228 "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
227229
230 "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
231
228232 "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
229233
230234 "alova": ["alova@3.5.5", "", { "dependencies": { "@alova/shared": "1.3.4", "rate-limiter-flexible": "^5.0.3" } }, "sha512-V/w9xLNBpmBxx7Zeb+OAJapt9yD1L/hBoqi86xlRtug94F2xUkA1GFsJMeDeV/oL/iQCvW8fQyplGOiLXDkACg=="],
@@ -285,6 +289,8 @@
285289
286290 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
287291
292 "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
293
288294 "empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="],
289295
290296 "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -403,6 +409,8 @@
403409
404410 "lucide-react": ["lucide-react@1.39.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA=="],
405411
412 "marked": ["marked@18.0.13", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw=="],
413
406414 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
407415
408416 "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
Modified web/package.json View web/package.json
@@ -15,7 +15,9 @@
1515 "@alova/wormhole": "^1.5.2",
1616 "@tailwindcss/vite": "^4.3.3",
1717 "alova": "^3.5.5",
18 "dompurify": "^3.4.15",
1819 "lucide-react": "^1.39.0",
20 "marked": "^18.0.13",
1921 "react": "^19.2.8",
2022 "react-dom": "^19.2.8",
2123 "react-router-dom": "^7.18.3",
Modified Cargo.lock View Cargo.lock
@@ -23,6 +23,7 @@
2323 "ci",
2424 "config",
2525 "export",
26 "git",
2627 "models",
2728 "redis",
2829 "reqwest 0.12.28",