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
19 changes: 18 additions & 1 deletion docs/en/wework/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,30 @@ Common macOS shortcuts include:
local custom models, and cloud models remain available according to their own configuration. If
no source provides a model, the picker displays **No models available**.

## Friendly titles

Under **Settings → General → Task runtime**, enable **Use friendly titles** and select an available model. Wework
then generates a concise title asynchronously for each new task. Creation never waits for that
model: the task starts with the original user-provided title, and its display name is replaced
when generation finishes with a brief shimmer.

Title generation uses a separate lightweight model call. It does not add a message to the task
conversation or change the main task's running, completed, or failed state. A manually renamed
task is not overwritten later. If generation fails, the original title remains and the task still
starts normally.

Ordinary new tasks are standalone by default. Opening a project task earlier does not
automatically add a new task to a remote project or board. Wework synchronizes only tasks that
were explicitly linked to a project space; after friendly-title generation, those local or cloud
board items receive the updated title.

## Custom Codex models

In **Settings → Models**, click **Add model** and choose a provider first. Wework includes profiles for Kimi Coding, the Kimi API Platform, DeepSeek, and GLM. After entering the corresponding platform API key, Wework discovers available models through the provider's `/models` endpoint. Each profile supplies its connection URL, API protocol, tool mode, and known model context windows; the Kimi API Platform profile uses the China-region `api.moonshot.cn` endpoint. Kimi Coding K3 automatically uses the built-in Codex Catalog profile with a 256K context window and `low` default reasoning effort.

The DeepSeek profile uses the native Responses API and exposes `deepseek-v4-flash` and `deepseek-v4-pro`, the models currently available for Codex. Both use a 1,048,576-token context window, `high` default reasoning effort, live Web Search, and freeform `apply_patch`. The connection test also requires a real `apply_patch` custom-tool call. Existing Wework-managed DeepSeek Chat Completions configurations are migrated to the Responses API. The current catalog declares text input and disables image generation, so image generation and image understanding are not presented as supported capabilities for this profile.

A text-only model can reference another model that explicitly declares image input as its vision proxy. Local-model references are maintained in Wework's local model settings. Cloud-model references must be configured in the Wegent web administration UI; Wework only consumes the `visionSidecarModel` reference returned by the Backend and does not provide cloud-model editing. Before the primary request, Wework asks the vision model to describe each image and replaces `input_image` with the description text. Every text model with a configured vision proxy uses the internal generic `wework-vision-sidecar` capability catalog entry, allowing Codex to accept images while sending the original media only to the vision model and never to the text-only primary model. Vision proxies support OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages endpoints. Each turn describes at most eight distinct images, accepts images up to 20 MB, and injects an explicit error message when description fails. Remote vision endpoints must use HTTPS; only `localhost` and loopback IP addresses may use HTTP, and vision requests do not follow redirects so credentials cannot be forwarded to an unauthorized destination.
A text-only model can reference another model that explicitly declares image input as its vision proxy. Local-model references are maintained in Wework's local model settings. Cloud-model references must be configured in the Wegent web administration UI; Wework only consumes the `visionSidecarModel` reference returned by the Backend and does not provide cloud-model editing. Before the primary request, Wework asks the vision model to describe each image and replaces `input_image` with the description text. Every text model with a configured vision proxy uses the internal generic `wework-vision-sidecar` capability catalog entry, allowing Codex to accept images while sending the original media only to the vision model and never to the text-only primary model. Vision proxies support OpenAI Responses, OpenAI Chat Completions, and Anthropic Messages endpoints. Each turn describes at most eight distinct images, accepts images up to 20 MB, and injects an explicit error message when description fails. Remote vision endpoints must use HTTPS; only `localhost` and loopback IP addresses may use HTTP, and vision requests do not follow redirects so credentials cannot be forwarded to an unauthorized destination.

Each custom model has an optional **Group** field that controls how it appears in the model picker. Kimi Coding defaults this field to **Kimi**, but users can edit or clear it. Models without a group appear under **Custom models**.

Expand Down
16 changes: 15 additions & 1 deletion docs/zh/wework/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,27 @@ Windows 和 Linux 使用界面中显示的对应组合键。
`auth.json` 时,该分组不会出现在模型选择器中;Provider 模型、本地自定义模型和云端模型
仍按各自配置展示。如果所有来源都没有可用模型,选择器直接显示“暂无可用模型”。

## 使用友好标题

在 **设置 → 通用 → 任务运行** 中打开 **使用友好标题**,并选择一个可用模型后,Wework 会在新建任务
时异步生成简洁的中文标题。创建不会等待标题模型返回:任务先使用用户输入的原始标题创建,
标题生成完成后才替换显示名称并出现短暂扫光效果。

标题生成使用独立的轻量模型调用,不会向任务会话写入消息,也不会改变主任务的运行、完成或
失败状态。手动重命名的任务不会被之后的自动标题覆盖。生成失败时,原始标题会保留,任务仍可
正常创建和运行。

普通新建任务默认是独立会话,不会因为之前打开过项目任务而自动加入远程项目或看板。只有在
发送前明确选择项目空间,或为任务显式关联项目空间时,Wework 才会同步该任务;已关联的任务
在友好标题生成后会将更新后的标题同步到对应的本地或云端看板。

## 自定义 Codex 模型

在“设置 → 模型”中点击“添加模型”后,先选择提供商。Wework 内置 Kimi Coding、Kimi 开放平台、DeepSeek 和 GLM profile;填写对应平台的 API Key 后,可以从提供商的 `/models` 接口读取可用模型。连接地址、接口协议、工具模式和已知模型的上下文长度由 profile 自动填写,其中 Kimi 开放平台使用中国区 `api.moonshot.cn` 端点。Kimi Coding 的 K3 会自动使用内置的 Codex Catalog profile,包括 256K 上下文和默认 `low` 推理等级。

DeepSeek profile 使用原生 Responses API,展示当前可用于 Codex 的 `deepseek-v4-flash` 和 `deepseek-v4-pro`。两者均使用 1,048,576 tokens 上下文、默认 `high` 推理等级、实时 Web Search 和 freeform `apply_patch`;连接测试也会要求模型真实返回 `apply_patch` custom tool call。旧版由 Wework 管理的 DeepSeek Chat Completions 配置会自动迁移到 Responses API。当前模型目录声明文本输入且关闭图片生成,因此图片生成和图片理解不会在该 profile 下显示为可用能力。

文本模型可以引用另一个明确声明图片输入能力的模型作为“视觉代理模型”。本地模型的引用在 Wework 本地模型设置中维护;云端模型的引用必须在 Wegent Web 管理端配置,Wework 只读取 Backend 下发的 `visionSidecarModel` 引用,不提供云端模型编辑入口。执行时,Wework 在请求主模型前调用视觉模型描述每张图片,再用描述文本替换 `input_image`。任何配置视觉代理的文本模型都会使用内部通用的 `wework-vision-sidecar` 能力目录项,使 Codex 可以接收图片,同时原始图片只发送给视觉模型,不发送给文本主模型。视觉代理支持 OpenAI Responses、OpenAI Chat Completions 和 Anthropic Messages 接口;单轮最多处理 8 个不同图片描述,单张图片最大 20 MB,描述失败时会插入明确的错误文本。远程视觉接口必须使用 HTTPS;仅 `localhost` 和回环 IP 可使用 HTTP,且视觉请求不会跟随重定向,避免凭证被发送到未授权的后续地址。
文本模型可以引用另一个明确声明图片输入能力的模型作为“视觉代理模型”。本地模型的引用在 Wework 本地模型设置中维护;云端模型的引用必须在 Wegent Web 管理端配置,Wework 只读取 Backend 下发的 `visionSidecarModel` 引用,不提供云端模型编辑入口。执行时,Wework 在请求主模型前调用视觉模型描述每张图片,再用描述文本替换 `input_image`。任何配置视觉代理的文本模型都会使用内部通用的 `wework-vision-sidecar` 能力目录项,使 Codex 可以接收图片,同时原始图片只发送给视觉模型,不发送给文本主模型。视觉代理支持 OpenAI Responses、OpenAI Chat Completions 和 Anthropic Messages 接口;单轮最多处理 8 个不同图片描述,单张图片最大 20 MB,描述失败时会插入明确的错误文本。远程视觉接口必须使用 HTTPS;仅 `localhost` 和回环 IP 可使用 HTTP,且视觉请求不会跟随重定向,避免凭证被发送到未授权的后续地址。

每个自定义模型都可以设置可选的“分组”,模型选择器会使用该名称组织模型。Kimi Coding 默认填写“Kimi”,用户可以修改或清空;未设置分组的模型统一显示在“自定义模型”下。

Expand Down
219 changes: 218 additions & 1 deletion executor/src/runtime_work/handler/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,146 @@
use super::*;

impl RuntimeWorkRpcHandler {
pub(super) async fn generate_friendly_title(
&self,
payload: Value,
) -> Result<Value, AppIpcError> {
let source_title = string_field(&payload, "sourceTitle")
.or_else(|| string_field(&payload, "source_title"))
.ok_or_else(|| AppIpcError::new("bad_request", "sourceTitle is required"))?;
let link = self.task_link_from_payload(&payload, false).await?;
if link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", link.local_task_id),
("reason", "title_changed_before_generation".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
let local_task_id = link.local_task_id.clone();
let mut request = execution_request(&payload)
.ok_or_else(|| AppIpcError::new("bad_request", "executionRequest is required"))?;
request.ephemeral = true;
let result = self
.codex_app_server
.run_turn_with_cancel(request.clone(), CodexAppServerTurnOptions::default())
.await;
let content = match result {
Ok(turn) => match turn.outcome {
ExecutionOutcome::Completed { content } => content,
ExecutionOutcome::Failed { message } => {
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "model_request_failed".to_owned()),
("error", message.clone()),
],
);
return Ok(json!({"success": false, "error": message}));
}
outcome => {
let error = format!("friendly title generation did not complete: {outcome:?}");
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "model_request_incomplete".to_owned()),
("error", error.clone()),
],
);
return Ok(json!({"success": false, "error": error}));
}
},
Err(error) => {
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "model_transport_failed".to_owned()),
("error", error.clone()),
],
);
return Ok(json!({"success": false, "error": error}));
}
};
let Some(title) = normalize_friendly_title(&content) else {
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "empty_model_output".to_owned()),
],
);
return Ok(json!({"success": false, "error": "friendly title was empty"}));
};
let mut latest_link = self.task_link_from_payload(&payload, false).await?;
for _ in 0..25 {
if latest_link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "title_changed_during_generation".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
if latest_link.thread_id.is_some() {
break;
}
sleep(Duration::from_millis(200)).await;
latest_link = self.task_link_from_payload(&payload, false).await?;
}
if latest_link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "title_changed_before_update".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
if let Some(thread_id) = latest_link.thread_id.as_deref() {
if let Err(error) = self
.call_codex_thread_method(
"thread/name/set",
json!({"threadId": thread_id, "name": title}),
)
.await
{
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "thread_name_update_failed".to_owned()),
("error", error.clone()),
],
);
return Ok(json!({"success": false, "error": error}));
}
}
latest_link.title = title.clone();
latest_link.updated_at = now_ms();
self.upsert_local_task(latest_link);
Comment on lines +83 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Replace the whole-link upsert with an in-place update_task mutation.

latest_link is read at Line 99 (or Line 83) and written back in full at Line 132. Friendly-title generation runs concurrently with the main turn spawned by create_task, and that turn mutates the same stored link through mark_task_running_for_send and record_runtime_turn_id. Any field those paths write between the read and upsert_local_task is reverted by this stale snapshot, including thread_id, runtime_handle turn mappings, updated_at, and completed_at. The title check at Line 101 is also a time-of-check/time-of-use gap: a rename that lands after the check is overwritten.

Mutate only the title through self.store.update_task, and re-verify the source title inside the closure so the guard and the write are atomic.

🐛 Proposed fix
-        latest_link.title = title.clone();
-        latest_link.updated_at = now_ms();
-        self.upsert_local_task(latest_link);
+        let applied = self
+            .store
+            .update_task(&local_task_id, |link| {
+                if link.title != source_title {
+                    return;
+                }
+                link.title = title.clone();
+                link.updated_at = now_ms();
+            })
+            .is_some();
+        if !applied {
+            log_executor_event(
+                "friendly task title generation skipped",
+                &[
+                    ("local_task_id", local_task_id.clone()),
+                    ("reason", "task_missing_before_update".to_owned()),
+                ],
+            );
+            return Ok(json!({"success": true, "skipped": true}));
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut latest_link = self.task_link_from_payload(&payload, false).await?;
for _ in 0..25 {
if latest_link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "title_changed_during_generation".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
if latest_link.thread_id.is_some() {
break;
}
sleep(Duration::from_millis(200)).await;
latest_link = self.task_link_from_payload(&payload, false).await?;
}
if latest_link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "title_changed_before_update".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
if let Some(thread_id) = latest_link.thread_id.as_deref() {
if let Err(error) = self
.call_codex_thread_method(
"thread/name/set",
json!({"threadId": thread_id, "name": title}),
)
.await
{
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "thread_name_update_failed".to_owned()),
("error", error.clone()),
],
);
return Ok(json!({"success": false, "error": error}));
}
}
latest_link.title = title.clone();
latest_link.updated_at = now_ms();
self.upsert_local_task(latest_link);
let mut latest_link = self.task_link_from_payload(&payload, false).await?;
for _ in 0..25 {
if latest_link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "title_changed_during_generation".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
if latest_link.thread_id.is_some() {
break;
}
sleep(Duration::from_millis(200)).await;
latest_link = self.task_link_from_payload(&payload, false).await?;
}
if latest_link.title != source_title {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "title_changed_before_update".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
if let Some(thread_id) = latest_link.thread_id.as_deref() {
if let Err(error) = self
.call_codex_thread_method(
"thread/name/set",
json!({"threadId": thread_id, "name": title}),
)
.await
{
log_executor_event(
"friendly task title generation failed",
&[
("local_task_id", local_task_id.clone()),
("reason", "thread_name_update_failed".to_owned()),
("error", error.clone()),
],
);
return Ok(json!({"success": false, "error": error}));
}
}
let applied = self
.store
.update_task(&local_task_id, |link| {
if link.title != source_title {
return;
}
link.title = title.clone();
link.updated_at = now_ms();
})
.is_some();
if !applied {
log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "task_missing_before_update".to_owned()),
],
);
return Ok(json!({"success": true, "skipped": true}));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@executor/src/runtime_work/handler/tasks.rs` around lines 83 - 132, Replace
the final latest_link mutation and upsert_local_task call in the friendly-title
generation flow with an in-place self.store.update_task mutation. Inside its
closure, re-read and verify the stored task title still equals source_title,
then update only title and updated_at; otherwise return the existing skipped
result. Preserve concurrent fields such as thread_id, runtime_handle mappings,
and completed_at, and propagate or map update errors consistently with the
surrounding handler.

emit_response_event(
&self.event_tx,
&self.device_id,
"runtime.task.title.updated",
&local_task_id,
&request,
json!({"title": title}),
);
log_executor_event(
"friendly task title generation completed",
&[("local_task_id", local_task_id)],
);
Ok(json!({"success": true, "title": title}))
}

pub(super) async fn fork_task_at_turn(&self, payload: Value) -> Result<Value, AppIpcError> {
let source = self.task_link_from_payload(&payload, false).await?;
let requested_turn_id = string_field(&payload, "lastTurnId")
Expand Down Expand Up @@ -189,9 +329,49 @@ impl RuntimeWorkRpcHandler {
fork_thread_id: side_source.as_ref().map(|source| source.thread_id.clone()),
fork_thread_path: side_source.and_then(|source| source.thread_path),
resume_thread_id: None,
initial_thread_name: Some(title),
initial_thread_name: Some(title.clone()),
initial_thread_goal,
});
match payload.get("friendlyTitleExecutionRequest").cloned() {
Some(value) => match serde_json::from_value::<ExecutionRequest>(value) {
Ok(execution_request) => {
log_executor_event(
"friendly task title generation queued",
&[("local_task_id", local_task_id.clone())],
);
let handler = self.clone();
let friendly_payload = json!({
"taskId": local_task_id,
"sourceTitle": title,
"executionRequest": execution_request,
});
tokio::spawn(async move {
if let Err(error) = handler.generate_friendly_title(friendly_payload).await
{
log_executor_event(
"friendly task title generation failed",
&[("error", error.message)],
);
}
});
}
Err(error) => log_executor_event(
"friendly task title generation rejected",
&[
("local_task_id", local_task_id.clone()),
("reason", "invalid_execution_request".to_owned()),
("error", error.to_string()),
],
),
},
None => log_executor_event(
"friendly task title generation skipped",
&[
("local_task_id", local_task_id.clone()),
("reason", "missing_execution_request".to_owned()),
],
),
}

Ok(json!({
"success": true,
Expand Down Expand Up @@ -801,6 +981,25 @@ impl RuntimeWorkRpcHandler {
}
}

fn normalize_friendly_title(value: &str) -> Option<String> {
let title = value
.lines()
.find_map(|line| {
let trimmed = line
.trim()
.trim_matches(|ch| matches!(ch, '"' | '\'' | '“' | '”' | '‘' | '’' | '`'));
(!trimmed.is_empty()).then_some(trimmed)
})?
.chars()
.take(48)
.collect::<String>()
.trim()
.trim_end_matches(['。', '!', '?', '.', '!', '?'])
.trim()
.to_owned();
(!title.is_empty()).then_some(title)
}

pub(super) fn runtime_model_selection_changed(link: &RuntimeTaskLink, payload: &Value) -> bool {
let previous = model_selection_identity(
link.runtime_handle
Expand Down Expand Up @@ -914,3 +1113,21 @@ fn synthetic_transcript_turn_id_base(value: &str) -> Option<&str> {
}
Some(turn_id)
}

#[cfg(test)]
mod tests {
use super::normalize_friendly_title;

#[test]
fn normalizes_model_title_to_one_short_line() {
assert_eq!(
normalize_friendly_title(" “为任务创建友好标题。”\n这是解释"),
Some("为任务创建友好标题".to_owned())
);
}

#[test]
fn rejects_empty_model_title() {
assert_eq!(normalize_friendly_title(" \n\t "), None);
}
}
Loading
Loading